From f365cee732423879e2838c2655797b74470e6509 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:59:45 +0100 Subject: [PATCH 001/123] chore(deps): bump @babel/helpers from 7.24.7 to 7.26.10 in /docs (#9576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@babel/helpers](https://github.com/babel/babel/tree/HEAD/packages/babel-helpers) from 7.24.7 to 7.26.10.
Release notes

Sourced from @​babel/helpers's releases.

v7.26.10 (2025-03-11)

Thanks @​jordan-choi and @​mmmsssttt404 for your first PRs!

This release includes a fix for https://github.com/babel/babel/security/advisories/GHSA-968p-4wvh-cqc8, a security vulnerability which affects the .replace method of transpiled regular expressions that use named capturing groups.

:eyeglasses: Spec Compliance

:bug: Bug Fix

:nail_care: Polish

:house: Internal

Committers: 6

v7.26.9 (2025-02-14)

:bug: Bug Fix

... (truncated)

Changelog

Sourced from @​babel/helpers's changelog.

v7.26.10 (2025-03-11)

:eyeglasses: Spec Compliance

:bug: Bug Fix

:nail_care: Polish

:house: Internal

v7.26.9 (2025-02-14)

:bug: Bug Fix

:house: Internal

v7.26.7 (2025-01-24)

:bug: Bug Fix

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@babel/helpers&package-manager=npm_and_yarn&previous-version=7.24.7&new-version=7.26.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/zitadel/zitadel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Skewis --- docs/yarn.lock | 53 +++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index 63a626af05..61e00b199d 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -185,6 +185,15 @@ "@babel/highlight" "^7.24.7" picocolors "^1.0.0" +"@babel/code-frame@^7.26.2": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" + integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== + dependencies: + "@babel/helper-validator-identifier" "^7.25.9" + js-tokens "^4.0.0" + picocolors "^1.0.0" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.7.tgz#d23bbea508c3883ba8251fb4164982c36ea577ed" @@ -389,11 +398,21 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz#4d2d0f14820ede3b9807ea5fc36dfc8cd7da07f2" integrity sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg== +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + "@babel/helper-validator-identifier@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + "@babel/helper-validator-option@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6" @@ -410,12 +429,12 @@ "@babel/types" "^7.24.7" "@babel/helpers@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.7.tgz#aa2ccda29f62185acb5d42fb4a3a1b1082107416" - integrity sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg== + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.0.tgz#53d156098defa8243eab0f32fa17589075a1b808" + integrity sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg== dependencies: - "@babel/template" "^7.24.7" - "@babel/types" "^7.24.7" + "@babel/template" "^7.27.0" + "@babel/types" "^7.27.0" "@babel/highlight@^7.24.7": version "7.24.7" @@ -432,6 +451,13 @@ resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.7.tgz#9a5226f92f0c5c8ead550b750f5608e766c8ce85" integrity sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw== +"@babel/parser@^7.27.0": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.0.tgz#3d7d6ee268e41d2600091cbd4e145ffee85a44ec" + integrity sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg== + dependencies: + "@babel/types" "^7.27.0" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.7.tgz#fd059fd27b184ea2b4c7e646868a9a381bbc3055" @@ -1207,6 +1233,15 @@ "@babel/parser" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/template@^7.27.0": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.0.tgz#b253e5406cc1df1c57dcd18f11760c2dbf40c0b4" + integrity sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/parser" "^7.27.0" + "@babel/types" "^7.27.0" + "@babel/traverse@^7.22.8", "@babel/traverse@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" @@ -1232,6 +1267,14 @@ "@babel/helper-validator-identifier" "^7.24.7" to-fast-properties "^2.0.0" +"@babel/types@^7.27.0": + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.0.tgz#ef9acb6b06c3173f6632d993ecb6d4ae470b4559" + integrity sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + "@braintree/sanitize-url@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" From 4e3da63b6721a3557b072a68773ce32409ce6029 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 11:28:11 +0100 Subject: [PATCH 002/123] chore(deps): bump @babel/runtime from 7.24.7 to 7.26.10 in /docs (#9575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.24.7 to 7.26.10.
Release notes

Sourced from @​babel/runtime's releases.

v7.26.10 (2025-03-11)

Thanks @​jordan-choi and @​mmmsssttt404 for your first PRs!

This release includes a fix for https://github.com/babel/babel/security/advisories/GHSA-968p-4wvh-cqc8, a security vulnerability which affects the .replace method of transpiled regular expressions that use named capturing groups.

:eyeglasses: Spec Compliance

:bug: Bug Fix

  • babel-parser, babel-template
  • babel-core
  • babel-parser, babel-plugin-transform-typescript
  • babel-traverse
  • babel-generator
  • babel-parser
  • babel-helpers, babel-runtime, babel-runtime-corejs2, babel-runtime-corejs3

:nail_care: Polish

  • babel-standalone

:house: Internal

Committers: 6

v7.26.9 (2025-02-14)

:bug: Bug Fix

... (truncated)

Changelog

Sourced from @​babel/runtime's changelog.

v7.26.10 (2025-03-11)

:eyeglasses: Spec Compliance

:bug: Bug Fix

  • babel-parser, babel-template
  • babel-core
  • babel-parser, babel-plugin-transform-typescript
  • babel-traverse
  • babel-generator
  • babel-parser
  • babel-helpers, babel-runtime, babel-runtime-corejs2, babel-runtime-corejs3

:nail_care: Polish

  • babel-standalone

:house: Internal

v7.26.9 (2025-02-14)

:bug: Bug Fix

:house: Internal

v7.26.7 (2025-01-24)

:bug: Bug Fix

  • babel-helpers, babel-preset-env, babel-runtime-corejs3
  • babel-plugin-transform-typeof-symbol

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@babel/runtime&package-manager=npm_and_yarn&previous-version=7.24.7&new-version=7.26.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/zitadel/zitadel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: David Skewis --- docs/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/yarn.lock b/docs/yarn.lock index 61e00b199d..ad31e03b5e 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -1218,9 +1218,9 @@ regenerator-runtime "^0.14.0" "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.22.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12" - integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw== + version "7.27.0" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" + integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== dependencies: regenerator-runtime "^0.14.0" From 88493dd2a0c2e929d6151cd307d4514caa555224 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Mon, 14 Apr 2025 13:37:47 +0200 Subject: [PATCH 003/123] docs: strikethrough deprecated APIs (#9740) # Which Problems Are Solved The docs overview pages and navs don't visually distinguish between deprecated and GA APIs. This makes it hard to find the right methods for the job already. As we are implementing the resource API and continously deprecate obsolete APIs, this only gets worse. # How the Problems Are Solved The UI items in docs overview pages are striked through and pushed to the bottom of the list. This applies to side navs as well as card lists. For example, [see management user methods](https://docs-git-strikethrough-deprecated-apis-zitadel.vercel.app/docs/apis/resources/mgmt/users): ![image](https://github.com/user-attachments/assets/a12ccd92-3a70-4854-8ebf-b771ff151083) A method is considered deprecated if it has this option set in the protos rpc definition: ```protobuf option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { deprecated: true; } ``` # Additional Changes None # Additional Context - Relates to #9680 --------- Co-authored-by: David Skewis --- docs/src/css/custom.css | 30 ++++++++++++++++++++++++- docs/src/theme/DocCardList/index.js | 13 +++++++++++ docs/src/theme/DocSidebarItems/index.js | 14 ++++++++++++ docs/src/utils/deprecated-items.js | 29 ++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 docs/src/theme/DocCardList/index.js create mode 100644 docs/src/theme/DocSidebarItems/index.js create mode 100644 docs/src/utils/deprecated-items.js diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 5083c842e9..50035f2541 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -612,4 +612,32 @@ p strong { position: absolute; top: 0; left: 0; -} \ No newline at end of file +} + +/* + The overview list components are enriched by swizzled DocCardList and DocSidebarItems wrappers. + Deprecated list item titles have the class zitadel-lifecycle-deprecated. + We strike them through, because this is well-known practice and reduces mental overhead for finding the right method to solve a task. + */ +.card:has(.zitadel-lifecycle-deprecated) { + position: relative; +} + +.card:has(.zitadel-lifecycle-deprecated)::after { + content: "DEPRECATED"; + position: absolute; + top: 10px; + right: 10px; + background-color: var(--ifm-color-danger-contrast-background); + color: var(--ifm-color-danger-contrast-foreground); + font-size: 12px; + font-weight: 600; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.zitadel-lifecycle-deprecated { + text-decoration: line-through; +} diff --git a/docs/src/theme/DocCardList/index.js b/docs/src/theme/DocCardList/index.js new file mode 100644 index 0000000000..c9c24e2b8f --- /dev/null +++ b/docs/src/theme/DocCardList/index.js @@ -0,0 +1,13 @@ +import React from 'react'; +import DocCardList from '@theme-original/DocCardList'; +import toCustomDeprecatedItemsProps from "../../utils/deprecated-items"; + +// The DocCardList component is used in generated index pages for API services. +// We customize it for deprecated items. +export default function DocCardListWrapper(props) { + return ( + <> + + + ); +} diff --git a/docs/src/theme/DocSidebarItems/index.js b/docs/src/theme/DocSidebarItems/index.js new file mode 100644 index 0000000000..35ad429fa2 --- /dev/null +++ b/docs/src/theme/DocSidebarItems/index.js @@ -0,0 +1,14 @@ +import React from 'react'; +import DocSidebarItems from '@theme-original/DocSidebarItems'; +import toCustomDeprecatedItemsProps from '../../utils/deprecated-items.js'; + +// The DocSidebarItems component is used in generated side navs for API services. +// We wrap the original to push deprecated items to the bottom and give them a CSS class. +// This lets us easily style them differently in docs/src/css/custom.css. +export default function DocSidebarItemsWrapper(props) { + return ( + <> + + + ); +} diff --git a/docs/src/utils/deprecated-items.js b/docs/src/utils/deprecated-items.js new file mode 100644 index 0000000000..353a619d30 --- /dev/null +++ b/docs/src/utils/deprecated-items.js @@ -0,0 +1,29 @@ +import React from "react"; + +// This function changes a ListComponents input properties. +// Deprecated items are pushed to the bottom of the list and its labels are given the CSS class zitadel-lifecycle-deprecated. +// They are styled in docs/src/css/custom.css. +export default function (props) { + const { items = [], ...rest } = props; + if (!Array.isArray(items)) { + // Do nothing if items is not an array + return props; + } + const withDeprecated = [...items].map(({className, label, ...itemRest}) => { + const zitadelLifecycleDeprecated = className?.indexOf('menu__list-item--deprecated') > -1 + const wrappedLabel = {label} + return { + zitadelLifecycleDeprecated: zitadelLifecycleDeprecated, + ...itemRest, + className, + label: wrappedLabel, + }; + }); + const sortedItems = [...withDeprecated].sort((a, b) => { + return a.zitadelLifecycleDeprecated - b.zitadelLifecycleDeprecated; + }); + return { + ...rest, + items: sortedItems, + }; +} From bb59192e3e96b9b448f734b3725588f399f32741 Mon Sep 17 00:00:00 2001 From: Trong Huu Nguyen Date: Mon, 14 Apr 2025 16:57:51 +0200 Subject: [PATCH 004/123] fix(console): correct count for users list, show create timestamp in user details (#9705) This pull request fixes a couple of minor issues with the user list and details pages in Console. # Which Problems Are Solved 1. The total count in the users list was the total number of results returned. This made the pagination not work when there were more than `pageSize * 2` users. 2. The user details page did not show the created timestamp when viewing a user. # How the Problems Are Solved 1. The response includes the total number calculated by the backend. Use that instead. 2. Inverse the ternary returning the creation date. # Additional Changes None # Additional Context None --------- Co-authored-by: Thomas Krampl --- console/src/app/modules/info-row/info-row.component.ts | 7 ++----- .../users/user-list/user-table/user-table.component.ts | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/console/src/app/modules/info-row/info-row.component.ts b/console/src/app/modules/info-row/info-row.component.ts index 2689584813..85d6a1bdc6 100644 --- a/console/src/app/modules/info-row/info-row.component.ts +++ b/console/src/app/modules/info-row/info-row.component.ts @@ -66,14 +66,11 @@ export class InfoRowComponent { } public get changeDate() { - return this?.user?.details?.changeDate; + return this.user?.details?.changeDate; } public get creationDate() { - if (!this.user) { - return undefined; - } - return '$typeName' in this.user ? undefined : this.user.details?.creationDate; + return this.user?.details?.creationDate; } private getEmail(user: User.AsObject | UserV2 | UserV1) { diff --git a/console/src/app/pages/users/user-list/user-table/user-table.component.ts b/console/src/app/pages/users/user-list/user-table/user-table.component.ts index 29de192ec2..6ff0d2cd67 100644 --- a/console/src/app/pages/users/user-list/user-table/user-table.component.ts +++ b/console/src/app/pages/users/user-list/user-table/user-table.component.ts @@ -119,7 +119,7 @@ export class UserTableComponent implements OnInit { this.dataSize = toSignal( this.users$.pipe( - map((users) => users.result.length), + map((users) => Number(users.details?.totalResult ?? users.result.length)), distinctUntilChanged(), ), { initialValue: 0 }, From 3b8a2ab8114e5933ad852d53b45b366973e1e0f9 Mon Sep 17 00:00:00 2001 From: Kenta Yamaguchi <56732734+KEY60228@users.noreply.github.com> Date: Tue, 15 Apr 2025 18:40:25 +0900 Subject: [PATCH 005/123] chore(i18n): add IAM_LOGIN_CLIENT (#9681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved The i18n element `IAM_LOGIN_CLIENT` is missing a translation. # How the Problems Are Solved Added translations for `IAM_LOGIN_CLIENT` in each language. Please note that the translations were generated using Copilot, so they may not be entirely accurate (I'm only confident that they are correct for English and Japanese). I appreciate any corrections or improvements. Co-authored-by: Tim Möhlmann --- console/src/assets/i18n/bg.json | 1 + console/src/assets/i18n/cs.json | 1 + console/src/assets/i18n/de.json | 1 + console/src/assets/i18n/en.json | 1 + console/src/assets/i18n/es.json | 1 + console/src/assets/i18n/fr.json | 1 + console/src/assets/i18n/hu.json | 1 + console/src/assets/i18n/id.json | 1 + console/src/assets/i18n/it.json | 1 + console/src/assets/i18n/ja.json | 1 + console/src/assets/i18n/ko.json | 1 + console/src/assets/i18n/mk.json | 1 + console/src/assets/i18n/nl.json | 1 + console/src/assets/i18n/pl.json | 1 + console/src/assets/i18n/pt.json | 1 + console/src/assets/i18n/ro.json | 1 + console/src/assets/i18n/ru.json | 1 + console/src/assets/i18n/sv.json | 1 + console/src/assets/i18n/zh.json | 1 + 19 files changed, 19 insertions(+) diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index f1929cef14..48e7e687f6 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -642,6 +642,7 @@ "IAM_USER_MANAGER": "Има разрешение за създаване и управление на потребители", "IAM_ADMIN_IMPERSONATOR": "Има разрешение да се представя за администратор и крайни потребители от всички организации", "IAM_END_USER_IMPERSONATOR": "Има разрешение да се представя за крайни потребители от всички организации", + "IAM_LOGIN_CLIENT": "Има разрешение за управление на клиенти за вход", "ORG_OWNER": "Има разрешение за цялата организация", "ORG_USER_MANAGER": "Има разрешение да създава и управлява потребители на организацията", "ORG_OWNER_VIEWER": "Има разрешение за преглед на цялата организация", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index ba7f4b1ada..c4dc6aa1cc 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Má oprávnění vytvářet a spravovat uživatele", "IAM_ADMIN_IMPERSONATOR": "Má oprávnění vydávat se za správce a koncové uživatele ze všech organizací", "IAM_END_USER_IMPERSONATOR": "Má oprávnění vydávat se za koncové uživatele ze všech organizací", + "IAM_LOGIN_CLIENT": "Má oprávnění spravovat přihlašovací klienty", "ORG_OWNER": "Má oprávnění nad celou organizací", "ORG_USER_MANAGER": "Má oprávnění vytvářet a spravovat uživatele organizace", "ORG_OWNER_VIEWER": "Má oprávnění prohlížet celou organizaci", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 79b9596678..a5c861e891 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Hat die Berechtigung zum Erstellen und Verwalten von Benutzern", "IAM_ADMIN_IMPERSONATOR": "Hat die Berechtigung, sich als Administrator und Endbenutzer aller Organisationen auszugeben", "IAM_END_USER_IMPERSONATOR": "Hat die Berechtigung, sich als Endbenutzer aller Organisationen auszugeben", + "IAM_LOGIN_CLIENT": "Hat die Berechtigung, Anmeldeclients zu verwalten", "ORG_OWNER": "Hat die Berechtigung für die gesamte Organisation", "ORG_USER_MANAGER": "Hat die Berechtigung, Benutzer der Organisation zu erstellen und zu verwalten", "ORG_OWNER_VIEWER": "Hat die Leseberechtigung, die gesamte Organisation zu überprüfen", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 19759d0041..6e682f05c0 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Has permission to create and manage users", "IAM_ADMIN_IMPERSONATOR": "Has permission to impersonate admin and end users from all organizations", "IAM_END_USER_IMPERSONATOR": "Has permission to impersonate end users from all organizations", + "IAM_LOGIN_CLIENT": "Has permission to manage login clients", "ORG_OWNER": "Has permission over the whole organization", "ORG_USER_MANAGER": "Has permission to create and manage users of the organization", "ORG_OWNER_VIEWER": "Has permission to review the whole organization", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 6855d0dcbf..1f90490561 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Tiene permiso para crear y gestionar usuarios", "IAM_ADMIN_IMPERSONATOR": "Tiene permiso para hacerse pasar por administradores y usuarios finales de todas las organizaciones", "IAM_END_USER_IMPERSONATOR": "Tiene permiso para hacerse pasar por usuarios finales de todas las organizaciones", + "IAM_LOGIN_CLIENT": "Tiene permiso para gestionar los clientes de inicio de sesión", "ORG_OWNER": "Tiene permisos sobre toda la organización", "ORG_USER_MANAGER": "Tiene permiso para crear y gestionar usuarios de la organización", "ORG_OWNER_VIEWER": "TIene permiso para revisar toda la organización", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 47acd0ac9f..a23f87ac79 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "A le droit de créer et de gérer les utilisateurs", "IAM_ADMIN_IMPERSONATOR": "A l'autorisation de se faire passer pour l'administrateur et les utilisateurs finaux de toutes les organisations", "IAM_END_USER_IMPERSONATOR": "Est autorisé à usurper l'identité des utilisateurs finaux de toutes les organisations", + "IAM_LOGIN_CLIENT": "A la permission de gérer les clients de connexion", "ORG_OWNER": "A le droit de contrôler l'ensemble de l'organisation", "ORG_USER_MANAGER": "A le droit de créer et de gérer les utilisateurs de l'organisation", "ORG_OWNER_VIEWER": "A le droit de passer en revue l'ensemble de l'organisation", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 8e1f8ad7d2..313257e23d 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Jogosultsága van felhasználók létrehozására és kezelésére", "IAM_ADMIN_IMPERSONATOR": "Jogosultsága van adminok és végfelhasználók megszemélyesítésére minden szervezetből", "IAM_END_USER_IMPERSONATOR": "Engedélye van az összes szervezet véghasználóinak megszemélyesítésére", + "IAM_LOGIN_CLIENT": "Jogosultsága van a bejelentkezési kliensek kezelésére", "ORG_OWNER": "Engedélye van az egész szervezet fölött", "ORG_USER_MANAGER": "Engedélye van a szervezet felhasználóinak létrehozására és kezelésére", "ORG_OWNER_VIEWER": "Engedélye van az egész szervezet áttekintésére", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index d5cef2c054..a9eba9f73e 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -610,6 +610,7 @@ "IAM_USER_MANAGER": "Memiliki izin untuk membuat dan mengelola pengguna", "IAM_ADMIN_IMPERSONATOR": "Memiliki izin untuk menyamar sebagai admin dan pengguna akhir dari semua organisasi", "IAM_END_USER_IMPERSONATOR": "Memiliki izin untuk meniru identitas pengguna akhir dari semua organisasi", + "IAM_LOGIN_CLIENT": "Memiliki izin untuk mengelola klien masuk", "ORG_OWNER": "Memiliki izin atas seluruh organisasi", "ORG_USER_MANAGER": "Memiliki izin untuk membuat dan mengelola pengguna organisasi", "ORG_OWNER_VIEWER": "Memiliki izin untuk meninjau seluruh organisasi", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index a42059d11c..a9bcb40a73 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -642,6 +642,7 @@ "IAM_USER_MANAGER": "Ha l'autorizzazione per creare e gestire utenti", "IAM_ADMIN_IMPERSONATOR": "Dispone dell'autorizzazione per rappresentare l'amministratore e gli utenti finali di tutte le organizzazioni", "IAM_END_USER_IMPERSONATOR": "Dispone dell'autorizzazione per rappresentare gli utenti finali di tutte le organizzazioni", + "IAM_LOGIN_CLIENT": "Ha il permesso di gestire i client di accesso", "ORG_OWNER": "Ha il permesso su tutta l'organizzazione", "ORG_USER_MANAGER": "Ha l'autorizzazione per creare e gestire gli utenti dell'organizzazione", "ORG_OWNER_VIEWER": "Ha il permesso di esaminare l'intera organizzazione", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 8a9574b7d9..d09249694f 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "ユーザーの作成および管理する権限を持ちます", "IAM_ADMIN_IMPERSONATOR": "すべての組織の管理者およびエンドユーザーになりすます権限を持っています", "IAM_END_USER_IMPERSONATOR": "すべての組織のエンドユーザーになりすます権限を持っています", + "IAM_LOGIN_CLIENT": "ログインクライアントを管理する権限を持っています", "ORG_OWNER": "組織全体に対する権限を持ちます", "ORG_USER_MANAGER": "組織のユーザーを作成および管理する権限を持ちます", "ORG_OWNER_VIEWER": "組織全体を閲覧する権限を持ちます", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index c596b3b067..0f805f5479 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "사용자를 생성하고 관리할 수 있는 권한이 있습니다", "IAM_ADMIN_IMPERSONATOR": "모든 조직의 관리자와 최종 사용자를 대리할 수 있는 권한이 있습니다", "IAM_END_USER_IMPERSONATOR": "모든 조직의 최종 사용자를 대리할 수 있는 권한이 있습니다", + "IAM_LOGIN_CLIENT": "로그인 클라이언트를 관리할 수 있는 권한이 있습니다", "ORG_OWNER": "조직에 대한 전체 권한이 있습니다", "ORG_USER_MANAGER": "조직의 사용자를 생성하고 관리할 수 있는 권한이 있습니다", "ORG_OWNER_VIEWER": "조직 전체를 검토할 수 있는 권한이 있습니다", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index c01b485f0a..134af90cef 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Има дозвола за креирање и менаџирање на корисници", "IAM_ADMIN_IMPERSONATOR": "Има дозвола да се претставува како администратор и крајни корисници од сите организации", "IAM_END_USER_IMPERSONATOR": "Има дозвола да ги имитира крајните корисници од сите организации", + "IAM_LOGIN_CLIENT": "Има дозвола за менаџирање на клиенти за најава", "ORG_OWNER": "Има дозвола врз целата организација", "ORG_USER_MANAGER": "Има дозвола за креирање и менаџирање на корисници во организацијата", "ORG_OWNER_VIEWER": "Има дозвола за преглед на целата организација", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index ab8243e2a5..db462ea0dd 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Heeft toestemming om gebruikers aan te maken en te beheren", "IAM_ADMIN_IMPERSONATOR": "Heeft toestemming om zich voor te doen als beheerder en eindgebruikers van alle organisaties", "IAM_END_USER_IMPERSONATOR": "Heeft toestemming om eindgebruikers van alle organisaties na te bootsen", + "IAM_LOGIN_CLIENT": "Heeft toestemming om aanmeldklanten te beheren", "ORG_OWNER": "Heeft toestemming over de hele organisatie", "ORG_USER_MANAGER": "Heeft toestemming om gebruikers van de organisatie aan te maken en te beheren", "ORG_OWNER_VIEWER": "Heeft toestemming om de hele organisatie te bekijken", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index b071c4f99a..6f68e79524 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -642,6 +642,7 @@ "IAM_USER_MANAGER": "Ma uprawnienie do tworzenia i zarządzania użytkownikami", "IAM_ADMIN_IMPERSONATOR": "Ma uprawnienia do podszywania się pod administratora i użytkowników końcowych ze wszystkich organizacji", "IAM_END_USER_IMPERSONATOR": "Ma uprawnienia do podszywania się pod użytkowników końcowych ze wszystkich organizacji", + "IAM_LOGIN_CLIENT": "Ma uprawnienia do zarządzania klientami logowania", "ORG_OWNER": "Ma uprawnienie nad całą organizacją", "ORG_USER_MANAGER": "Ma uprawnienie do tworzenia i zarządzania użytkownikami organizacji", "ORG_OWNER_VIEWER": "Ma uprawnienie do przeglądania całej organizacji", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index a8ab833724..c083cb5079 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Tem permissão para criar e gerenciar usuários", "IAM_ADMIN_IMPERSONATOR": "Tem permissão para se passar por administradores e usuários finais de todas as organizações", "IAM_END_USER_IMPERSONATOR": "Tem permissão para se passar por usuários finais de todas as organizações", + "IAM_LOGIN_CLIENT": "Tem permissão para gerenciar clientes de login", "ORG_OWNER": "Tem permissão sobre toda a organização", "ORG_USER_MANAGER": "Tem permissão para criar e gerenciar usuários da organização", "ORG_OWNER_VIEWER": "Tem permissão para revisar toda a organização", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index 987368d84f..5938368345 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Are permisiunea de a crea și gestiona utilizatori", "IAM_ADMIN_IMPERSONATOR": "Are permisiunea de a impersona administratorul și utilizatorii finali din toate organizațiile", "IAM_END_USER_IMPERSONATOR": "Are permisiunea de a impersona utilizatorii finali din toate organizațiile", + "IAM_LOGIN_CLIENT": "Are permisiunea de a gestiona clientii de login", "ORG_OWNER": "Are permisiunea asupra întregii organizații", "ORG_USER_MANAGER": "Are permisiunea de a crea și gestiona utilizatorii organizației", "ORG_OWNER_VIEWER": "Are permisiunea de a revizui întreaga organizație", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 1289fb708f..a612a586b3 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Имеет разрешение на создание и управление пользователями", "IAM_ADMIN_IMPERSONATOR": "Имеет разрешение выдавать себя за администратора и конечных пользователей из всех организаций", "IAM_END_USER_IMPERSONATOR": "Имеет разрешение выдавать себя за конечных пользователей из всех организаций", + "IAM_LOGIN_CLIENT": "Имеет разрешение на управление клиентами входа", "ORG_OWNER": "Имеет разрешение на всю организацию", "ORG_USER_MANAGER": "Имеет разрешение на создание и управление пользователями организации", "ORG_OWNER_VIEWER": "Имеет разрешение на просмотр всей организации", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 44ca2fc200..1cae15230b 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "Har behörighet att skapa och hantera användare", "IAM_ADMIN_IMPERSONATOR": "Har behörighet att imitera administratörer och slutanvändare från alla organisationer", "IAM_END_USER_IMPERSONATOR": "Har behörighet att imitera slutanvändare från alla organisationer", + "IAM_LOGIN_CLIENT": "Har behörighet att hantera inloggningsklienter", "ORG_OWNER": "Har behörighet över hela organisationen", "ORG_USER_MANAGER": "Har behörighet att skapa och hantera användare i organisationen", "ORG_OWNER_VIEWER": "Har behörighet att granska hela organisationen", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 5df3b414ab..1a3895aa7b 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -643,6 +643,7 @@ "IAM_USER_MANAGER": "有权创建和管理用户", "IAM_ADMIN_IMPERSONATOR": "有权模拟所有组织的管理员和最终用户", "IAM_END_USER_IMPERSONATOR": "有权模拟所有组织的最终用户", + "IAM_LOGIN_CLIENT": "具有管理登录客户端的权限", "ORG_OWNER": "拥有整个组织的权限", "ORG_USER_MANAGER": "有权创建和管理组织的用户", "ORG_OWNER_VIEWER": "有权审查整个组织", From a2f60f2e7af4d5c3282d1a568fa04492e8b1a7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 15 Apr 2025 19:38:25 +0300 Subject: [PATCH 006/123] perf(query): org permission function for resources (#9677) # Which Problems Are Solved Classic permission checks execute for every returned row on resource based search APIs. Complete background and problem definition can be found here: https://github.com/zitadel/zitadel/issues/9188 # How the Problems Are Solved - PermissionClause function now support dynamic query building, so it supports multiple cases. - PermissionClause is applied to all list resources which support org level permissions. - Wrap permission logic into wrapper functions so we keep the business logic clean. # Additional Changes - Handle org ID optimization in the query package, so it is reusable for all resources, instead of extracting the filter in the API. - Cleanup and test system user conversion in the authz package. (context middleware) - Fix: `core_integration_db_up` make recipe was missing the postgres service. # Additional Context - Related to https://github.com/zitadel/zitadel/issues/9190 --- Makefile | 2 +- internal/api/authz/authorization.go | 50 ++---- internal/api/authz/authorization_test.go | 126 ++++++++++++++ internal/api/authz/context.go | 35 ++-- internal/api/authz/membertype_enumer.go | 20 ++- internal/api/grpc/admin/export.go | 2 +- internal/api/grpc/admin/org.go | 2 +- internal/api/grpc/management/org.go | 2 +- internal/api/grpc/management/user.go | 2 +- internal/api/grpc/user/v2/query.go | 25 ++- internal/api/grpc/user/v2beta/query.go | 25 ++- internal/api/scim/resources/user.go | 2 +- internal/api/ui/login/login.go | 2 +- internal/database/type.go | 34 ++++ internal/database/type_test.go | 87 ++++++++++ internal/query/idp_user_link.go | 27 ++- internal/query/org.go | 19 ++- internal/query/permission.go | 153 ++++++++++------- internal/query/permission_test.go | 208 +++++++++++++++++++++++ internal/query/query.go | 13 ++ internal/query/session.go | 24 ++- internal/query/user.go | 33 ++-- internal/query/user_auth_method.go | 20 ++- 23 files changed, 741 insertions(+), 172 deletions(-) create mode 100644 internal/query/permission_test.go diff --git a/Makefile b/Makefile index b5145cef3d..3c50231bee 100644 --- a/Makefile +++ b/Makefile @@ -112,7 +112,7 @@ core_unit_test: .PHONY: core_integration_db_up core_integration_db_up: - docker compose -f internal/integration/config/docker-compose.yaml up --pull always --wait cache + docker compose -f internal/integration/config/docker-compose.yaml up --pull always --wait cache postgres .PHONY: core_integration_db_down core_integration_db_down: diff --git a/internal/api/authz/authorization.go b/internal/api/authz/authorization.go index ea20a2438f..25130584a0 100644 --- a/internal/api/authz/authorization.go +++ b/internal/api/authz/authorization.go @@ -7,8 +7,6 @@ import ( "slices" "strings" - "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -26,14 +24,13 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, ctx, span := tracing.NewServerInterceptorSpan(ctx) defer func() { span.EndWithError(err) }() - ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier) + ctxData, err := VerifyTokenAndCreateCtxData(ctx, token, orgID, orgDomain, verifier, systemRolePermissionMapping) if err != nil { return nil, err } if requiredAuthOption.Permission == authenticated { return func(parent context.Context) context.Context { - parent = addGetSystemUserRolesToCtx(parent, systemRolePermissionMapping, ctxData) return context.WithValue(parent, dataKey, ctxData) }, nil } @@ -54,7 +51,6 @@ func CheckUserAuthorization(ctx context.Context, req interface{}, token, orgID, parent = context.WithValue(parent, dataKey, ctxData) parent = context.WithValue(parent, allPermissionsKey, allPermissions) parent = context.WithValue(parent, requestPermissionsKey, requestedPermissions) - parent = addGetSystemUserRolesToCtx(parent, systemRolePermissionMapping, ctxData) return parent }, nil } @@ -131,42 +127,32 @@ func GetAllPermissionCtxIDs(perms []string) []string { return ctxIDs } -type SystemUserPermissionsDBQuery struct { - MemberType string `json:"member_type"` - AggregateID string `json:"aggregate_id"` - ObjectID string `json:"object_id"` - Permissions []string `json:"permissions"` +type SystemUserPermissions struct { + MemberType MemberType `json:"member_type"` + AggregateID string `json:"aggregate_id"` + ObjectID string `json:"object_id"` + Permissions []string `json:"permissions"` } -func addGetSystemUserRolesToCtx(ctx context.Context, systemUserRoleMap []RoleMapping, ctxData CtxData) context.Context { - if len(ctxData.SystemMemberships) == 0 { - return ctx +// systemMembershipsToUserPermissions converts system memberships based on roles, +// to SystemUserPermissions, using the passed role mapping. +func systemMembershipsToUserPermissions(memberships Memberships, roleMap []RoleMapping) []SystemUserPermissions { + if memberships == nil { + return nil } - systemUserPermissions := make([]SystemUserPermissionsDBQuery, len(ctxData.SystemMemberships)) - for i, systemPerm := range ctxData.SystemMemberships { + systemUserPermissions := make([]SystemUserPermissions, len(memberships)) + for i, systemPerm := range memberships { permissions := make([]string, 0, len(systemPerm.Roles)) for _, role := range systemPerm.Roles { - permissions = append(permissions, getPermissionsFromRole(systemUserRoleMap, role)...) + permissions = append(permissions, getPermissionsFromRole(roleMap, role)...) } slices.Sort(permissions) - permissions = slices.Compact(permissions) + permissions = slices.Compact(permissions) // remove duplicates - systemUserPermissions[i].MemberType = systemPerm.MemberType.String() + systemUserPermissions[i].MemberType = systemPerm.MemberType systemUserPermissions[i].AggregateID = systemPerm.AggregateID + systemUserPermissions[i].ObjectID = systemPerm.ObjectID systemUserPermissions[i].Permissions = permissions } - return context.WithValue(ctx, systemUserRolesKey, systemUserPermissions) -} - -func GetSystemUserPermissions(ctx context.Context) []SystemUserPermissionsDBQuery { - getSystemUserRolesFuncValue := ctx.Value(systemUserRolesKey) - if getSystemUserRolesFuncValue == nil { - return nil - } - systemUserRoles, ok := getSystemUserRolesFuncValue.([]SystemUserPermissionsDBQuery) - if !ok { - logging.WithFields("Authz").Error("unable to cast []SystemUserPermissionsDBQuery") - return nil - } - return systemUserRoles + return systemUserPermissions } diff --git a/internal/api/authz/authorization_test.go b/internal/api/authz/authorization_test.go index 4b81c73d81..af49dcc5c6 100644 --- a/internal/api/authz/authorization_test.go +++ b/internal/api/authz/authorization_test.go @@ -3,6 +3,8 @@ package authz import ( "testing" + "github.com/stretchr/testify/assert" + "github.com/zitadel/zitadel/internal/zerrors" ) @@ -276,3 +278,127 @@ func Test_GetPermissionCtxIDs(t *testing.T) { }) } } + +func Test_systemMembershipsToUserPermissions(t *testing.T) { + roleMap := []RoleMapping{ + { + Role: "FOO_BAR", + Permissions: []string{"foo.bar.read", "foo.bar.write"}, + }, + { + Role: "BAR_FOO", + Permissions: []string{"bar.foo.read", "bar.foo.write", "foo.bar.read"}, + }, + } + + type args struct { + memberships Memberships + roleMap []RoleMapping + } + tests := []struct { + name string + args args + want []SystemUserPermissions + }{ + { + name: "nil memberships", + args: args{ + memberships: nil, + roleMap: roleMap, + }, + want: nil, + }, + { + name: "empty memberships", + args: args{ + memberships: Memberships{}, + roleMap: roleMap, + }, + want: []SystemUserPermissions{}, + }, + { + name: "single membership", + args: args{ + memberships: Memberships{ + { + MemberType: MemberTypeSystem, + AggregateID: "1", + ObjectID: "2", + Roles: []string{"FOO_BAR"}, + }, + }, + roleMap: roleMap, + }, + want: []SystemUserPermissions{ + { + MemberType: MemberTypeSystem, + AggregateID: "1", + ObjectID: "2", + Permissions: []string{"foo.bar.read", "foo.bar.write"}, + }, + }, + }, + { + name: "multiple memberships", + args: args{ + memberships: Memberships{ + { + MemberType: MemberTypeSystem, + AggregateID: "1", + ObjectID: "2", + Roles: []string{"FOO_BAR"}, + }, + { + MemberType: MemberTypeIAM, + AggregateID: "1", + ObjectID: "2", + Roles: []string{"BAR_FOO"}, + }, + }, + roleMap: roleMap, + }, + want: []SystemUserPermissions{ + { + MemberType: MemberTypeSystem, + AggregateID: "1", + ObjectID: "2", + Permissions: []string{"foo.bar.read", "foo.bar.write"}, + }, + { + MemberType: MemberTypeIAM, + AggregateID: "1", + ObjectID: "2", + Permissions: []string{"bar.foo.read", "bar.foo.write", "foo.bar.read"}, + }, + }, + }, + { + name: "multiple roles", + args: args{ + memberships: Memberships{ + { + MemberType: MemberTypeSystem, + AggregateID: "1", + ObjectID: "2", + Roles: []string{"FOO_BAR", "BAR_FOO"}, + }, + }, + roleMap: roleMap, + }, + want: []SystemUserPermissions{ + { + MemberType: MemberTypeSystem, + AggregateID: "1", + ObjectID: "2", + Permissions: []string{"bar.foo.read", "bar.foo.write", "foo.bar.read", "foo.bar.write"}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := systemMembershipsToUserPermissions(tt.args.memberships, tt.args.roleMap) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index d6528cd017..d12a1def44 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -1,4 +1,4 @@ -//go:generate enumer -type MemberType -trimprefix MemberType +//go:generate enumer -type MemberType -trimprefix MemberType -json package authz @@ -22,17 +22,17 @@ const ( dataKey key = 2 allPermissionsKey key = 3 instanceKey key = 4 - systemUserRolesKey key = 5 ) type CtxData struct { - UserID string - OrgID string - ProjectID string - AgentID string - PreferredLanguage string - ResourceOwner string - SystemMemberships Memberships + UserID string + OrgID string + ProjectID string + AgentID string + PreferredLanguage string + ResourceOwner string + SystemMemberships Memberships + SystemUserPermissions []SystemUserPermissions } func (ctxData CtxData) IsZero() bool { @@ -98,7 +98,7 @@ func (s SystemTokenVerifierFunc) VerifySystemToken(ctx context.Context, token st return s(ctx, token, orgID) } -func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t APITokenVerifier) (_ CtxData, err error) { +func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain string, t APITokenVerifier, systemRoleMap []RoleMapping) (_ CtxData, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() tokenWOBearer, err := extractBearerToken(token) @@ -133,13 +133,14 @@ func VerifyTokenAndCreateCtxData(ctx context.Context, token, orgID, orgDomain st } } return CtxData{ - UserID: userID, - OrgID: orgID, - ProjectID: projectID, - AgentID: agentID, - PreferredLanguage: prefLang, - ResourceOwner: resourceOwner, - SystemMemberships: sysMemberships, + UserID: userID, + OrgID: orgID, + ProjectID: projectID, + AgentID: agentID, + PreferredLanguage: prefLang, + ResourceOwner: resourceOwner, + SystemMemberships: sysMemberships, + SystemUserPermissions: systemMembershipsToUserPermissions(sysMemberships, systemRoleMap), }, nil } diff --git a/internal/api/authz/membertype_enumer.go b/internal/api/authz/membertype_enumer.go index 5de4c92292..a4275a2254 100644 --- a/internal/api/authz/membertype_enumer.go +++ b/internal/api/authz/membertype_enumer.go @@ -1,8 +1,9 @@ -// Code generated by "enumer -type MemberType -trimprefix MemberType"; DO NOT EDIT. +// Code generated by "enumer -type MemberType -trimprefix MemberType -json"; DO NOT EDIT. package authz import ( + "encoding/json" "fmt" "strings" ) @@ -92,3 +93,20 @@ func (i MemberType) IsAMemberType() bool { } return false } + +// MarshalJSON implements the json.Marshaler interface for MemberType +func (i MemberType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.String()) +} + +// UnmarshalJSON implements the json.Unmarshaler interface for MemberType +func (i *MemberType) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("MemberType should be a string, got %s", data) + } + + var err error + *i, err = MemberTypeString(s) + return err +} diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index da364909cb..68b6053c2c 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -554,7 +554,7 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w if err != nil { return nil, nil, nil, nil, err } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{orgSearch}}, org, nil) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{orgSearch}}, nil) if err != nil { return nil, nil, nil, nil, err } diff --git a/internal/api/grpc/admin/org.go b/internal/api/grpc/admin/org.go index 93e6936d42..293e7c74d7 100644 --- a/internal/api/grpc/admin/org.go +++ b/internal/api/grpc/admin/org.go @@ -108,7 +108,7 @@ func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain str if err != nil { return nil, err } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, "", nil) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/org.go b/internal/api/grpc/management/org.go index a6a934160a..70f509a4d7 100644 --- a/internal/api/grpc/management/org.go +++ b/internal/api/grpc/management/org.go @@ -329,7 +329,7 @@ func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain, or } queries = append(queries, owner) } - users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: queries}, orgID, nil) + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: queries}, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index b876999584..5b82eb5afe 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -69,7 +69,7 @@ func (s *Server) ListUsers(ctx context.Context, req *mgmt_pb.ListUsersRequest) ( if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries, orgID, nil) + res, err := s.query.SearchUsers(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index 23d4b4422c..136a4a0932 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -30,11 +30,11 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) } func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, filterOrgId, err := listUsersRequestToModel(req) + queries, err := listUsersRequestToModel(req) if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries, filterOrgId, s.checkPermission) + res, err := s.query.SearchUsers(ctx, queries, s.checkPermission) if err != nil { return nil, err } @@ -171,11 +171,11 @@ func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenT } } -func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, string, error) { +func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) { offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, filterOrgId, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) + queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) if err != nil { - return nil, "", err + return nil, err } return &query.UserSearchQueries{ SearchRequest: query.SearchRequest{ @@ -185,7 +185,7 @@ func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueri SortingColumn: userFieldNameToSortingColumn(req.SortingColumn), }, Queries: queries, - }, filterOrgId, nil + }, nil } func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { @@ -215,18 +215,15 @@ func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { } } -func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, filterOrgId string, err error) { +func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) { q := make([]query.SearchQuery, len(queries)) for i, query := range queries { - if orgFilter := query.GetOrganizationIdQuery(); orgFilter != nil { - filterOrgId = orgFilter.OrganizationId - } q[i], err = userQueryToQuery(query, level) if err != nil { - return nil, filterOrgId, err + return nil, err } } - return q, filterOrgId, nil + return q, nil } func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) { @@ -320,14 +317,14 @@ func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) { return query.NewUserInUserIdsSearchQuery(q.UserIds) } func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } return query.NewUserOrSearchQuery(mappedQueries) } func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index 7baa53e73e..e3602abc33 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -29,11 +29,11 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) } func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, filterOrgIds, err := listUsersRequestToModel(req) + queries, err := listUsersRequestToModel(req) if err != nil { return nil, err } - res, err := s.query.SearchUsers(ctx, queries, filterOrgIds, s.checkPermission) + res, err := s.query.SearchUsers(ctx, queries, s.checkPermission) if err != nil { return nil, err } @@ -165,11 +165,11 @@ func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenT } } -func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, string, error) { +func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) { offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, filterOrgId, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) + queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) if err != nil { - return nil, "", err + return nil, err } return &query.UserSearchQueries{ SearchRequest: query.SearchRequest{ @@ -179,7 +179,7 @@ func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueri SortingColumn: userFieldNameToSortingColumn(req.SortingColumn), }, Queries: queries, - }, filterOrgId, nil + }, nil } func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { @@ -209,18 +209,15 @@ func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { } } -func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, filterOrgId string, err error) { +func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) { q := make([]query.SearchQuery, len(queries)) for i, query := range queries { - if orgFilter := query.GetOrganizationIdQuery(); orgFilter != nil { - filterOrgId = orgFilter.OrganizationId - } q[i], err = userQueryToQuery(query, level) if err != nil { - return nil, filterOrgId, err + return nil, err } } - return q, filterOrgId, nil + return q, nil } func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) { @@ -314,14 +311,14 @@ func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) { return query.NewUserInUserIdsSearchQuery(q.UserIds) } func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } return query.NewUserOrSearchQuery(mappedQueries) } func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, _, err := userQueriesToQuery(q.Queries, level+1) + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) if err != nil { return nil, err } diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index ffd39aa23f..bc8d864994 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -240,7 +240,7 @@ func (h *UsersHandler) List(ctx context.Context, request *ListRequest) (*ListRes return NewListResponse(count, q.SearchRequest, make([]*ScimUser, 0)), nil } - users, err := h.query.SearchUsers(ctx, q, authz.GetCtxData(ctx).OrgID, nil) + users, err := h.query.SearchUsers(ctx, q, nil) if err != nil { return nil, err } diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index 4b028a347f..444c5aaa85 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -182,7 +182,7 @@ func (l *Login) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgName string if err != nil { return nil, err } - users, err := l.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, "", nil) + users, err := l.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: []query.SearchQuery{loginName}}, nil) if err != nil { return nil, err } diff --git a/internal/database/type.go b/internal/database/type.go index 6a781288a9..bd07e0bfde 100644 --- a/internal/database/type.go +++ b/internal/database/type.go @@ -225,3 +225,37 @@ func (d *NullDuration) Scan(src any) error { d.Duration, d.Valid = time.Duration(*duration), true return nil } + +// JSONArray allows sending and receiving JSON arrays to and from the database. +// It implements the [database/sql.Scanner] and [database/sql/driver.Valuer] interfaces. +// Values are marshaled and unmarshaled using the [encoding/json] package. +type JSONArray[T any] []T + +// NewJSONArray wraps an existing slice into a JSONArray. +func NewJSONArray[T any](a []T) JSONArray[T] { + return JSONArray[T](a) +} + +// Scan implements the [database/sql.Scanner] interface. +func (a *JSONArray[T]) Scan(src any) error { + if src == nil { + *a = nil + return nil + } + + bytes := src.([]byte) + if len(bytes) == 0 { + *a = nil + return nil + } + + return json.Unmarshal(bytes, a) +} + +// Value implements the [database/sql/driver.Valuer] interface. +func (a JSONArray[T]) Value() (driver.Value, error) { + if a == nil { + return nil, nil + } + return json.Marshal(a) +} diff --git a/internal/database/type_test.go b/internal/database/type_test.go index e56cdced76..7fab568a4e 100644 --- a/internal/database/type_test.go +++ b/internal/database/type_test.go @@ -5,6 +5,7 @@ import ( "database/sql/driver" "testing" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -452,3 +453,89 @@ func TestDuration_Scan(t *testing.T) { }) } } + +func TestJSONArray_Scan(t *testing.T) { + type args struct { + src any + } + tests := []struct { + name string + args args + want *JSONArray[string] + wantErr bool + }{ + { + name: "nil", + args: args{src: nil}, + want: new(JSONArray[string]), + wantErr: false, + }, + { + name: "zero bytes", + args: args{src: []byte("")}, + want: new(JSONArray[string]), + wantErr: false, + }, + { + name: "empty", + args: args{src: []byte("[]")}, + want: gu.Ptr(JSONArray[string]{}), + wantErr: false, + }, + { + name: "ok", + args: args{src: []byte("[\"a\", \"b\"]")}, + want: gu.Ptr(JSONArray[string]{"a", "b"}), + wantErr: false, + }, + { + name: "json error", + args: args{src: []byte("{\"a\": \"b\"}")}, + want: new(JSONArray[string]), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := new(JSONArray[string]) + err := got.Scan(tt.args.src) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestJSONArray_Value(t *testing.T) { + tests := []struct { + name string + a []string + want driver.Value + }{ + { + name: "nil", + a: nil, + want: nil, + }, + { + name: "empty", + a: []string{}, + want: []byte("[]"), + }, + { + name: "ok", + a: []string{"a", "b"}, + want: []byte("[\"a\",\"b\"]"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewJSONArray(tt.a).Value() + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/query/idp_user_link.go b/internal/query/idp_user_link.go index 23305dfd6e..99bf3c403b 100644 --- a/internal/query/idp_user_link.go +++ b/internal/query/idp_user_link.go @@ -106,12 +106,26 @@ func idpLinksCheckPermission(ctx context.Context, links *IDPUserLinks, permissio ) } +func idpLinksPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *IDPUserLinksSearchQuery) sq.SelectBuilder { + if !enabled { + return query + } + return query.Where(PermissionClause( + ctx, + IDPUserLinkResourceOwnerCol, + domain.PermissionUserRead, + SingleOrgPermissionOption(queries.Queries), + OwnedRowsPermissionOption(IDPUserLinkUserIDCol), + )) +} + func (q *Queries) IDPUserLinks(ctx context.Context, queries *IDPUserLinksSearchQuery, permissionCheck domain.PermissionCheck) (idps *IDPUserLinks, err error) { - links, err := q.idpUserLinks(ctx, queries, false) + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + links, err := q.idpUserLinks(ctx, queries, permissionCheckV2) if err != nil { return nil, err } - if permissionCheck != nil && len(links.Links) > 0 { + if permissionCheck != nil && len(links.Links) > 0 && !permissionCheckV2 { // when userID for query is provided, only one check has to be done if queries.hasUserID() { if err := userCheckPermission(ctx, links.Links[0].ResourceOwner, links.Links[0].UserID, permissionCheck); err != nil { @@ -124,14 +138,15 @@ func (q *Queries) IDPUserLinks(ctx context.Context, queries *IDPUserLinksSearchQ return links, nil } -func (q *Queries) idpUserLinks(ctx context.Context, queries *IDPUserLinksSearchQuery, withOwnerRemoved bool) (idps *IDPUserLinks, err error) { +func (q *Queries) idpUserLinks(ctx context.Context, queries *IDPUserLinksSearchQuery, permissionCheckV2 bool) (idps *IDPUserLinks, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareIDPUserLinksQuery() - eq := sq.Eq{IDPUserLinkInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID()} - if !withOwnerRemoved { - eq[IDPUserLinkOwnerRemovedCol.identifier()] = false + query = idpLinksPermissionCheckV2(ctx, query, permissionCheckV2, queries) + eq := sq.Eq{ + IDPUserLinkInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + IDPUserLinkOwnerRemovedCol.identifier(): false, } stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { diff --git a/internal/query/org.go b/internal/query/org.go index b1f5eaea02..643aec291a 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -93,6 +93,17 @@ func orgsCheckPermission(ctx context.Context, orgs *Orgs, permissionCheck domain ) } +func orgsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + return query.Where(PermissionClause( + ctx, + OrgColumnID, + domain_pkg.PermissionOrgRead, + )) +} + type OrgSearchQueries struct { SearchRequest Queries []SearchQuery @@ -283,21 +294,23 @@ func (q *Queries) ExistsOrg(ctx context.Context, id, domain string) (verifiedID } func (q *Queries) SearchOrgs(ctx context.Context, queries *OrgSearchQueries, permissionCheck domain_pkg.PermissionCheck) (*Orgs, error) { - orgs, err := q.searchOrgs(ctx, queries) + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + orgs, err := q.searchOrgs(ctx, queries, permissionCheckV2) if err != nil { return nil, err } - if permissionCheck != nil { + if permissionCheck != nil && !permissionCheckV2 { orgsCheckPermission(ctx, orgs, permissionCheck) } return orgs, nil } -func (q *Queries) searchOrgs(ctx context.Context, queries *OrgSearchQueries) (orgs *Orgs, err error) { +func (q *Queries) searchOrgs(ctx context.Context, queries *OrgSearchQueries, permissionCheckV2 bool) (orgs *Orgs, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareOrgsQuery() + query = orgsPermissionCheckV2(ctx, query, permissionCheckV2) stmt, args, err := queries.toQuery(query). Where(sq.And{ sq.Eq{ diff --git a/internal/query/permission.go b/internal/query/permission.go index c52b491144..3157430264 100644 --- a/internal/query/permission.go +++ b/internal/query/permission.go @@ -2,74 +2,109 @@ package query import ( "context" - "encoding/json" "fmt" sq "github.com/Masterminds/squirrel" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/internal/database" + domain_pkg "github.com/zitadel/zitadel/internal/domain" ) const ( - // eventstore.permitted_orgs(instanceid text, userid text, system_user_perms JSONB, perm text filter_orgs text) - wherePermittedOrgsClause = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))" - wherePermittedOrgsOrCurrentUserClause = "(" + wherePermittedOrgsClause + " OR %s = ?" + ")" + // eventstore.permitted_orgs(instanceid text, userid text, system_user_perms JSONB, perm text, filter_org text) + wherePermittedOrgsExpr = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))" ) -// wherePermittedOrgs sets a `WHERE` clause to the query that filters the orgs -// for which the authenticated user has the requested permission for. -// The user ID is taken from the context. -// The `orgIDColumn` specifies the table column to which this filter must be applied, -// and is typically the `resource_owner` column in ZITADEL. -// We use full identifiers in the query builder so this function should be -// called with something like `UserResourceOwnerCol.identifier()` for example. -// func wherePermittedOrgs(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, permission string) (sq.SelectBuilder, error) { -// userID := authz.GetCtxData(ctx).UserID -// logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "permission", permission, "user_id", userID).Debug("permitted orgs check used") - -// systemUserPermissions := authz.GetSystemUserPermissions(ctx) -// var systemUserPermissionsJson []byte -// if systemUserPermissions != nil { -// var err error -// systemUserPermissionsJson, err = json.Marshal(systemUserPermissions) -// if err != nil { -// return query, err -// } -// } - -// return query.Where( -// fmt.Sprintf(wherePermittedOrgsClause, orgIDColumn), -// authz.GetInstance(ctx).InstanceID(), -// userID, -// systemUserPermissionsJson, -// permission, -// filterOrgIds, -// ), nil -// } - -func wherePermittedOrgsOrCurrentUser(ctx context.Context, query sq.SelectBuilder, filterOrgIds, orgIDColumn, userIdColum, permission string) (sq.SelectBuilder, error) { - userID := authz.GetCtxData(ctx).UserID - logging.WithFields("permission_check_v2_flag", authz.GetFeatures(ctx).PermissionCheckV2, "org_id_column", orgIDColumn, "user_id_colum", userIdColum, "permission", permission, "user_id", userID).Debug("permitted orgs check used") - - systemUserPermissions := authz.GetSystemUserPermissions(ctx) - var systemUserPermissionsJson []byte - if systemUserPermissions != nil { - var err error - systemUserPermissionsJson, err = json.Marshal(systemUserPermissions) - if err != nil { - return query, zerrors.ThrowInternal(err, "AUTHZ-HS4us", "Errors.Internal") - } - } - - return query.Where( - fmt.Sprintf(wherePermittedOrgsOrCurrentUserClause, orgIDColumn, userIdColum), - authz.GetInstance(ctx).InstanceID(), - userID, - systemUserPermissionsJson, - permission, - filterOrgIds, - userID, - ), nil +type permissionClauseBuilder struct { + orgIDColumn Column + instanceID string + userID string + systemPermissions []authz.SystemUserPermissions + permission string + orgID string + connections []sq.Eq +} + +func (b *permissionClauseBuilder) appendConnection(column string, value any) { + b.connections = append(b.connections, sq.Eq{column: value}) +} + +func (b *permissionClauseBuilder) clauses() sq.Or { + clauses := make(sq.Or, 1, len(b.connections)+1) + clauses[0] = sq.Expr( + fmt.Sprintf(wherePermittedOrgsExpr, b.orgIDColumn.identifier()), + b.instanceID, + b.userID, + database.NewJSONArray(b.systemPermissions), + b.permission, + b.orgID, + ) + for _, include := range b.connections { + clauses = append(clauses, include) + } + return clauses +} + +type PermissionOption func(b *permissionClauseBuilder) + +// OwnedRowsPermissionOption allows rows to be returned of which the current user is the owner. +// Even if the user does not have an explicit permission for the organization. +// For example an authenticated user can always see his own user account. +func OwnedRowsPermissionOption(userIDColumn Column) PermissionOption { + return func(b *permissionClauseBuilder) { + b.appendConnection(userIDColumn.identifier(), b.userID) + } +} + +// ConnectionPermissionOption allows returning of rows where the value is matched. +// Even if the user does not have an explicit permission for the organization. +func ConnectionPermissionOption(column Column, value any) PermissionOption { + return func(b *permissionClauseBuilder) { + b.appendConnection(column.identifier(), value) + } +} + +// SingleOrgPermissionOption may be used to optimize the permitted orgs function by limiting the +// returned organizations, to the one used in the requested filters. +func SingleOrgPermissionOption(queries []SearchQuery) PermissionOption { + return func(b *permissionClauseBuilder) { + b.orgID = findTextEqualsQuery(b.orgIDColumn, queries) + } +} + +// PermissionClause sets a `WHERE` clause to query, +// which filters returned rows the current authenticated user has the requested permission to. +// +// Experimental: Work in progress. Currently only organization permissions are supported +func PermissionClause(ctx context.Context, orgIDCol Column, permission string, options ...PermissionOption) sq.Or { + ctxData := authz.GetCtxData(ctx) + b := &permissionClauseBuilder{ + orgIDColumn: orgIDCol, + instanceID: authz.GetInstance(ctx).InstanceID(), + userID: ctxData.UserID, + systemPermissions: ctxData.SystemUserPermissions, + permission: permission, + } + for _, opt := range options { + opt(b) + } + logging.WithFields( + "org_id_column", b.orgIDColumn, + "instance_id", b.instanceID, + "user_id", b.userID, + "system_user_permissions", b.systemPermissions, + "permission", b.permission, + "org_id", b.orgID, + "overrides", b.connections, + ).Debug("permitted orgs check used") + + return b.clauses() +} + +// PermissionV2 checks are enabled when the feature flag is set and the permission check function is not nil. +// When the permission check function is nil, it indicates a v1 API and no resource based permission check is needed. +func PermissionV2(ctx context.Context, cf domain_pkg.PermissionCheck) bool { + return authz.GetFeatures(ctx).PermissionCheckV2 && cf != nil } diff --git a/internal/query/permission_test.go b/internal/query/permission_test.go new file mode 100644 index 0000000000..f6ecd94b46 --- /dev/null +++ b/internal/query/permission_test.go @@ -0,0 +1,208 @@ +package query + +import ( + "context" + "testing" + + sq "github.com/Masterminds/squirrel" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + domain_pkg "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/feature" +) + +func TestPermissionClause(t *testing.T) { + var permissions = []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"permission1", "permission2"}, + }, + { + MemberType: authz.MemberTypeIAM, + Permissions: []string{"permission2", "permission3"}, + }, + } + ctx := authz.WithInstanceID(context.Background(), "instanceID") + ctx = authz.SetCtxData(ctx, authz.CtxData{ + UserID: "userID", + SystemUserPermissions: permissions, + }) + + type args struct { + ctx context.Context + orgIDCol Column + permission string + options []PermissionOption + } + tests := []struct { + name string + args args + wantClause sq.Or + }{ + { + name: "no options", + args: args{ + ctx: ctx, + orgIDCol: UserResourceOwnerCol, + permission: "permission1", + }, + wantClause: sq.Or{ + sq.Expr( + "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + "", + ), + }, + }, + { + name: "owned rows option", + args: args{ + ctx: ctx, + orgIDCol: UserResourceOwnerCol, + permission: "permission1", + options: []PermissionOption{ + OwnedRowsPermissionOption(UserIDCol), + }, + }, + wantClause: sq.Or{ + sq.Expr( + "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + "", + ), + sq.Eq{"projections.users14.id": "userID"}, + }, + }, + { + name: "connection rows option", + args: args{ + ctx: ctx, + orgIDCol: UserResourceOwnerCol, + permission: "permission1", + options: []PermissionOption{ + OwnedRowsPermissionOption(UserIDCol), + ConnectionPermissionOption(UserStateCol, "bar"), + }, + }, + wantClause: sq.Or{ + sq.Expr( + "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + "", + ), + sq.Eq{"projections.users14.id": "userID"}, + sq.Eq{"projections.users14.state": "bar"}, + }, + }, + { + name: "single org option", + args: args{ + ctx: ctx, + orgIDCol: UserResourceOwnerCol, + permission: "permission1", + options: []PermissionOption{ + SingleOrgPermissionOption([]SearchQuery{ + mustSearchQuery(NewUserDisplayNameSearchQuery("zitadel", TextContains)), + mustSearchQuery(NewUserResourceOwnerSearchQuery("orgID", TextEquals)), + }), + }, + }, + wantClause: sq.Or{ + sq.Expr( + "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + "orgID", + ), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotClause := PermissionClause(tt.args.ctx, tt.args.orgIDCol, tt.args.permission, tt.args.options...) + assert.Equal(t, tt.wantClause, gotClause) + }) + } +} + +func mustSearchQuery(q SearchQuery, err error) SearchQuery { + if err != nil { + panic(err) + } + return q +} + +func TestPermissionV2(t *testing.T) { + type args struct { + ctx context.Context + cf domain_pkg.PermissionCheck + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "feature disabled, no permission check", + args: args{ + ctx: context.Background(), + cf: nil, + }, + want: false, + }, + { + name: "feature enabled, no permission check", + args: args{ + ctx: authz.WithFeatures(context.Background(), feature.Features{ + PermissionCheckV2: true, + }), + cf: nil, + }, + want: false, + }, + { + name: "feature enabled, with permission check", + args: args{ + ctx: authz.WithFeatures(context.Background(), feature.Features{ + PermissionCheckV2: true, + }), + cf: func(context.Context, string, string, string) error { + return nil + }, + }, + want: true, + }, + { + name: "feature disabled, with permission check", + args: args{ + ctx: authz.WithFeatures(context.Background(), feature.Features{ + PermissionCheckV2: false, + }), + cf: func(context.Context, string, string, string) error { + return nil + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := PermissionV2(tt.args.ctx, tt.args.cf) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/query/query.go b/internal/query/query.go index c0c051f7b7..bd50d3c0be 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -148,3 +148,16 @@ func triggerBatch(ctx context.Context, handlers ...*handler.Handler) { wg.Wait() } + +func findTextEqualsQuery(column Column, queries []SearchQuery) string { + for _, query := range queries { + if query.Col() != column { + continue + } + tq, ok := query.(*textQuery) + if ok && tq.Compare == TextEquals { + return tq.Text + } + } + return "" +} diff --git a/internal/query/session.go b/internal/query/session.go index 111eb462a0..004f29fe81 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -113,6 +113,22 @@ func sessionCheckPermission(ctx context.Context, resourceOwner string, creator s return permissionCheck(ctx, domain.PermissionSessionRead, resourceOwner, "") } +func sessionsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + return query.Where(PermissionClause( + ctx, + SessionColumnResourceOwner, + domain.PermissionSessionRead, + // Allow if user is creator + OwnedRowsPermissionOption(SessionColumnCreator), + // Allow if session belongs to the user + OwnedRowsPermissionOption(SessionColumnUserID), + ConnectionPermissionOption(SessionColumnUserAgentFingerprintID, authz.GetCtxData(ctx).AgentID), + )) +} + func (q *SessionsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { query = q.SearchRequest.toQuery(query) for _, q := range q.Queries { @@ -282,21 +298,23 @@ func (q *Queries) sessionByID(ctx context.Context, shouldTriggerBulk bool, id st } func (q *Queries) SearchSessions(ctx context.Context, queries *SessionsSearchQueries, permissionCheck domain.PermissionCheck) (*Sessions, error) { - sessions, err := q.searchSessions(ctx, queries) + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + sessions, err := q.searchSessions(ctx, queries, permissionCheckV2) if err != nil { return nil, err } - if permissionCheck != nil { + if permissionCheck != nil && !permissionCheckV2 { sessionsCheckPermission(ctx, sessions, permissionCheck) } return sessions, nil } -func (q *Queries) searchSessions(ctx context.Context, queries *SessionsSearchQueries) (sessions *Sessions, err error) { +func (q *Queries) searchSessions(ctx context.Context, queries *SessionsSearchQueries, permissionCheckV2 bool) (sessions *Sessions, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareSessionsQuery() + query = sessionsPermissionCheckV2(ctx, query, permissionCheckV2) stmt, args, err := queries.toQuery(query). Where(sq.Eq{ SessionColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), diff --git a/internal/query/user.go b/internal/query/user.go index c30eaaec74..47694736c4 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -132,6 +132,19 @@ func usersCheckPermission(ctx context.Context, users *Users, permissionCheck dom ) } +func userPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *UserSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + return query.Where(PermissionClause( + ctx, + UserResourceOwnerCol, + domain.PermissionUserRead, + SingleOrgPermissionOption(queries.Queries), + OwnedRowsPermissionOption(UserIDCol), + )) +} + type UserSearchQueries struct { SearchRequest Queries []SearchQuery @@ -606,8 +619,9 @@ func (q *Queries) CountUsers(ctx context.Context, queries *UserSearchQueries) (c return count, err } -func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, filterOrgIds string, permissionCheck domain.PermissionCheck) (*Users, error) { - users, err := q.searchUsers(ctx, queries, filterOrgIds, permissionCheck != nil && authz.GetFeatures(ctx).PermissionCheckV2) +func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheck domain.PermissionCheck) (*Users, error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + users, err := q.searchUsers(ctx, queries, permissionCheckV2) if err != nil { return nil, err } @@ -617,22 +631,15 @@ func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries, f return users, nil } -func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, filterOrgIds string, permissionCheckV2 bool) (users *Users, err error) { +func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, permissionCheckV2 bool) (users *Users, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareUsersQuery() - query = queries.toQuery(query).Where(sq.Eq{ + query = userPermissionCheckV2(ctx, query, permissionCheckV2, queries) + stmt, args, err := queries.toQuery(query).Where(sq.Eq{ UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), - }) - if permissionCheckV2 { - query, err = wherePermittedOrgsOrCurrentUser(ctx, query, filterOrgIds, UserResourceOwnerCol.identifier(), UserIDCol.identifier(), domain.PermissionUserRead) - if err != nil { - return nil, zerrors.ThrowInternal(err, "AUTHZ-HS4us", "Errors.Internal") - } - } - - stmt, args, err := query.ToSql() + }).ToSql() if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-Dgbg2", "Errors.Query.SQLStatment") } diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go index 8b26389f1a..acf61bf0e6 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -104,6 +104,18 @@ func authMethodsCheckPermission(ctx context.Context, methods *AuthMethods, permi ) } +func userAuthMethodPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + return query.Where(PermissionClause( + ctx, + UserAuthMethodColumnResourceOwner, + domain.PermissionUserRead, + OwnedRowsPermissionOption(UserIDCol), + )) +} + type AuthMethod struct { UserID string CreationDate time.Time @@ -137,11 +149,12 @@ func (q *UserAuthMethodSearchQueries) hasUserID() bool { } func (q *Queries) SearchUserAuthMethods(ctx context.Context, queries *UserAuthMethodSearchQueries, permissionCheck domain.PermissionCheck) (userAuthMethods *AuthMethods, err error) { - methods, err := q.searchUserAuthMethods(ctx, queries) + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + methods, err := q.searchUserAuthMethods(ctx, queries, permissionCheckV2) if err != nil { return nil, err } - if permissionCheck != nil && len(methods.AuthMethods) > 0 { + if permissionCheck != nil && len(methods.AuthMethods) > 0 && !permissionCheckV2 { // when userID for query is provided, only one check has to be done if queries.hasUserID() { if err := userCheckPermission(ctx, methods.AuthMethods[0].ResourceOwner, methods.AuthMethods[0].UserID, permissionCheck); err != nil { @@ -154,11 +167,12 @@ func (q *Queries) SearchUserAuthMethods(ctx context.Context, queries *UserAuthMe return methods, nil } -func (q *Queries) searchUserAuthMethods(ctx context.Context, queries *UserAuthMethodSearchQueries) (userAuthMethods *AuthMethods, err error) { +func (q *Queries) searchUserAuthMethods(ctx context.Context, queries *UserAuthMethodSearchQueries, permissionCheckV2 bool) (userAuthMethods *AuthMethods, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareUserAuthMethodsQuery() + query = userAuthMethodPermissionCheckV2(ctx, query, permissionCheckV2) stmt, args, err := queries.toQuery(query).Where(sq.Eq{UserAuthMethodColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()}).ToSql() if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-j9NJd", "Errors.Query.InvalidRequest") From 618143931b22e3a7d1dc4a563124a92914ee2670 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 22 Apr 2025 08:22:54 +0200 Subject: [PATCH 007/123] chore(ci): fix container build (#9765) # Which Problems Are Solved While creating a new release, the [pipeline failed](https://github.com/zitadel/zitadel/actions/runs/14509737111/job/40705906723) as GH sunset the old actions cache service: https://github.blog/changelog/2025-03-20-notification-of-upcoming-breaking-changes-in-github-actions/#decommissioned-cache-service-brownouts # How the Problems Are Solved The `driver-opts` parameter is removed from the buildx actions to use the latest stable image. ([new cache service is used by BuildKit >= v0.20.0](https://docs.docker.com/build/ci/github-actions/cache/#cache-backend-api)) # Additional Changes Updated docker/build-push-action to v6 in a first attempt to solve the issue, but kept it as it gave some more insights (incl. build summary) # Additional Context Since the containers are only built on workflow triggers, here's the corresponding pipeline run: https://github.com/zitadel/zitadel/actions/runs/14513926232 --- .github/workflows/container.yml | 8 ++------ .github/workflows/e2e.yml | 2 -- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 5e22a67413..33ffd4f6af 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -53,8 +53,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver-opts: 'image=moby/buildkit:v0.11.6' - name: Login to Docker registry uses: docker/login-action@v3 @@ -75,7 +73,7 @@ jobs: - name: Debug id: build-debug - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 timeout-minutes: 3 with: context: . @@ -90,7 +88,7 @@ jobs: - name: Scratch id: build-scratch - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 timeout-minutes: 3 with: context: . @@ -147,8 +145,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver-opts: 'image=moby/buildkit:v0.11.6' - name: Login to Docker registry uses: docker/login-action@v3 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 88424d2bf8..e717163507 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -31,8 +31,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - driver-opts: 'image=moby/buildkit:v0.11.6' - name: Start DB and ZITADEL run: | From 658ca3606bd654040488361cf9aaa935eb809c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 22 Apr 2025 11:42:59 +0300 Subject: [PATCH 008/123] feat(permissions): project member permission filter (#9757) # Which Problems Are Solved Add the possibility to filter project resources based on project member roles. # How the Problems Are Solved Extend and refactor existing Pl/PgSQL functions to implement the following: - Solve O(n) complexity in returned resources IDs by returning a boolean filter for instance level permissions. - Individually permitted orgs are returned only if there was no instance permission - Individually permitted projects are returned only if there was no instance permission - Because of the multiple filter terms, use `INNER JOIN`s instead of `WHERE` clauses. # Additional Changes - system permission function no longer query the organization view and therefore can be `immutable`, giving big performance benefits for frequently reused system users. (like our hosted login in Zitadel cloud) - The permitted org and project functions are now defined as `stable` because the don't modify on-disk data. This might give a small performance gain - The Pl/PgSQL functions are now tested using Go unit tests. # Additional Context - Depends on https://github.com/zitadel/zitadel/pull/9677 - Part of https://github.com/zitadel/zitadel/issues/9188 - Closes https://github.com/zitadel/zitadel/issues/9190 --- cmd/defaults.yaml | 10 + cmd/setup/53.go | 2 +- cmd/setup/53/01-get-permissions-from-JSON.sql | 107 ++- cmd/setup/53/02-permitted_orgs_function.sql | 169 +--- cmd/setup/53/03-permitted_projects_func.sql | 58 ++ cmd/setup/integration_test/permission_test.go | 871 ++++++++++++++++++ cmd/setup/integration_test/setup_test.go | 41 + internal/api/authz/context.go | 2 +- internal/api/authz/membertype_enumer.go | 33 +- internal/query/idp_user_link.go | 5 +- internal/query/org.go | 5 +- internal/query/permission.go | 92 +- internal/query/permission_example_test.go | 78 ++ internal/query/permission_test.go | 133 ++- internal/query/query.go | 6 +- internal/query/session.go | 6 +- internal/query/user.go | 5 +- internal/query/user_auth_method.go | 5 +- 18 files changed, 1403 insertions(+), 225 deletions(-) create mode 100644 cmd/setup/53/03-permitted_projects_func.sql create mode 100644 cmd/setup/integration_test/permission_test.go create mode 100644 cmd/setup/integration_test/setup_test.go create mode 100644 internal/query/permission_example_test.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 30e037c80f..8482ccec9f 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -607,6 +607,16 @@ EncryptionKeys: UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID SystemAPIUsers: + - superuser: + Path: /path/to/superuser/key.pem + Memberships: + - MemberType: Organization + Roles: "ORG_OWNER" + AggregateID: "123456789012345678" + - MemberType: Project + Roles: "PROJECT_OWNER" + + # # Add keys for authentication of the systemAPI here: # # you can specify any name for the user, but they will have to match the `issuer` and `sub` claim in the JWT: # - superuser: diff --git a/cmd/setup/53.go b/cmd/setup/53.go index 83a7b1c0e2..952fc37916 100644 --- a/cmd/setup/53.go +++ b/cmd/setup/53.go @@ -33,5 +33,5 @@ func (mig *InitPermittedOrgsFunction53) Execute(ctx context.Context, _ eventstor } func (*InitPermittedOrgsFunction53) String() string { - return "53_init_permitted_orgs_function" + return "53_init_permitted_orgs_function_v2" } diff --git a/cmd/setup/53/01-get-permissions-from-JSON.sql b/cmd/setup/53/01-get-permissions-from-JSON.sql index b6415fa180..531184dbe4 100644 --- a/cmd/setup/53/01-get-permissions-from-JSON.sql +++ b/cmd/setup/53/01-get-permissions-from-JSON.sql @@ -1,23 +1,28 @@ +DROP FUNCTION IF EXISTS eventstore.check_system_user_perms; DROP FUNCTION IF EXISTS eventstore.get_system_permissions; +DROP TYPE IF EXISTS eventstore.project_grant; + +/* + Function get_system_permissions unpacks an JSON array of system member permissions, + into a table format. Each array entry maps to one row representing a membership which + contained the req_permission. + + [ + { + "member_type": "IAM", + "aggregate_id": "310716990375453665", + "object_id": "", + "permissions": ["iam.read", "iam.write", "iam.policy.read"] + }, + ... + ] + + | member_type | aggregate_id | object_id | + | "IAM" | "310716990375453665" | null | +*/ CREATE OR REPLACE FUNCTION eventstore.get_system_permissions( permissions_json JSONB - /* - [ - { - "member_type": "System", - "aggregate_id": "", - "object_id": "", - "permissions": ["iam.read", "iam.write", "iam.polic.read"] - }, - { - "member_type": "IAM", - "aggregate_id": "310716990375453665", - "object_id": "", - "permissions": ["iam.read", "iam.write", "iam.polic.read"] - } - ] - */ , permm TEXT ) RETURNS TABLE ( @@ -25,7 +30,7 @@ RETURNS TABLE ( aggregate_id TEXT, object_id TEXT ) - LANGUAGE 'plpgsql' + LANGUAGE 'plpgsql' IMMUTABLE AS $$ BEGIN RETURN QUERY @@ -37,7 +42,73 @@ BEGIN permission FROM jsonb_array_elements(permissions_json) AS perm CROSS JOIN jsonb_array_elements_text(perm->'permissions') AS permission) AS res - WHERE res. permission= permm; + WHERE res.permission = permm; END; $$; +/* + Type project_grant is composite identifier using its project and grant IDs. +*/ +CREATE TYPE eventstore.project_grant AS ( + project_id TEXT -- mapped from a permission's aggregate_id + , grant_id TEXT -- mapped from a permission's object_id +); + +/* + Function check_system_user_perms uses system member permissions to establish + on which organization, project or project grant the user has the requested permission. + The permission can also apply to the complete instance when a IAM membership matches + the requested instance ID, or through system membership. + + See eventstore.get_system_permissions() on the supported JSON format. +*/ +CREATE OR REPLACE FUNCTION eventstore.check_system_user_perms( + system_user_perms JSONB + , req_instance_id TEXT + , perm TEXT + + , instance_permitted OUT BOOLEAN + , org_ids OUT TEXT[] + , project_ids OUT TEXT[] + , project_grants OUT eventstore.project_grant[] +) + LANGUAGE 'plpgsql' IMMUTABLE +AS $$ +BEGIN + -- make sure no nulls are returned + instance_permitted := FALSE; + org_ids := ARRAY[]::TEXT[]; + project_ids := ARRAY[]::TEXT[]; + project_grants := ARRAY[]::eventstore.project_grant[]; + DECLARE + p RECORD; + BEGIN + FOR p IN SELECT member_type, aggregate_id, object_id + FROM eventstore.get_system_permissions(system_user_perms, perm) + LOOP + CASE p.member_type + WHEN 'System' THEN + instance_permitted := TRUE; + RETURN; + WHEN 'IAM' THEN + IF p.aggregate_id = req_instance_id THEN + instance_permitted := TRUE; + RETURN; + END IF; + WHEN 'Organization' THEN + IF p.aggregate_id != '' THEN + org_ids := array_append(org_ids, p.aggregate_id); + END IF; + WHEN 'Project' THEN + IF p.aggregate_id != '' THEN + project_ids := array_append(project_ids, p.aggregate_id); + END IF; + WHEN 'ProjectGrant' THEN + IF p.aggregate_id != '' THEN + project_grants := array_append(project_grants, ROW(p.aggregate_id, p.object_id)::eventstore.project_grant); + END IF; + END CASE; + END LOOP; + END; +END; +$$; diff --git a/cmd/setup/53/02-permitted_orgs_function.sql b/cmd/setup/53/02-permitted_orgs_function.sql index b6f61c6225..fbc7eaee59 100644 --- a/cmd/setup/53/02-permitted_orgs_function.sql +++ b/cmd/setup/53/02-permitted_orgs_function.sql @@ -1,144 +1,71 @@ -DROP FUNCTION IF EXISTS eventstore.check_system_user_perms; +DROP FUNCTION IF EXISTS eventstore.permitted_orgs; +DROP FUNCTION IF EXISTS eventstore.find_roles; -CREATE OR REPLACE FUNCTION eventstore.check_system_user_perms( - system_user_perms JSONB +-- find_roles finds all roles containing the permission +CREATE OR REPLACE FUNCTION eventstore.find_roles( + req_instance_id TEXT , perm TEXT - , filter_orgs TEXT - , org_ids OUT TEXT[] + , roles OUT TEXT[] ) - LANGUAGE 'plpgsql' +LANGUAGE 'plpgsql' STABLE AS $$ BEGIN - - WITH found_permissions(member_type, aggregate_id, object_id ) AS ( - SELECT * FROM eventstore.get_system_permissions( - system_user_perms, - perm) - ) - - SELECT array_agg(DISTINCT o.org_id) INTO org_ids - FROM eventstore.instance_orgs o, found_permissions - WHERE - CASE WHEN (SELECT TRUE WHERE found_permissions.member_type = 'System' LIMIT 1) THEN - TRUE - WHEN (SELECT TRUE WHERE found_permissions.member_type = 'IAM' LIMIT 1) THEN - -- aggregate_id not present - CASE WHEN (SELECT TRUE WHERE '' = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'IAM' - GROUP BY member_type - LIMIT 1 - )::TEXT[])) THEN - TRUE - -- aggregate_id is present - ELSE - o.instance_id = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'IAM' - GROUP BY member_type - LIMIT 1 - )::TEXT[]) - END - WHEN (SELECT TRUE WHERE found_permissions.member_type = 'Organization' LIMIT 1) THEN - -- aggregate_id not present - CASE WHEN (SELECT TRUE WHERE '' = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'Organization' - GROUP BY member_type - LIMIT 1 - )::TEXT[])) THEN - TRUE - -- aggregate_id is present - ELSE - o.org_id = ANY ( - ( - SELECT array_agg(found_permissions.aggregate_id) - FROM found_permissions - WHERE member_type = 'Organization' - GROUP BY member_type - LIMIT 1 - )::TEXT[]) - END - END - AND - CASE WHEN filter_orgs != '' - THEN o.org_id IN (filter_orgs) - ELSE TRUE END - LIMIT 1; + SELECT array_agg(rp.role) INTO roles + FROM eventstore.role_permissions rp + WHERE rp.instance_id = req_instance_id + AND rp.permission = perm; END; $$; - -DROP FUNCTION IF EXISTS eventstore.permitted_orgs; - CREATE OR REPLACE FUNCTION eventstore.permitted_orgs( - instanceId TEXT - , userId TEXT + req_instance_id TEXT + , auth_user_id TEXT , system_user_perms JSONB , perm TEXT - , filter_orgs TEXT + , filter_org TEXT + , instance_permitted OUT BOOLEAN , org_ids OUT TEXT[] ) - LANGUAGE 'plpgsql' + LANGUAGE 'plpgsql' STABLE AS $$ BEGIN - - -- if system user - IF system_user_perms IS NOT NULL THEN - org_ids := eventstore.check_system_user_perms(system_user_perms, perm, filter_orgs); - -- if human/machine user - ELSE + -- if system user + IF system_user_perms IS NOT NULL THEN + SELECT p.instance_permitted, p.org_ids INTO instance_permitted, org_ids + FROM eventstore.check_system_user_perms(system_user_perms, req_instance_id, perm) p; + RETURN; + END IF; + + -- if human/machine user DECLARE - matched_roles TEXT[]; -- roles containing permission - BEGIN - - SELECT array_agg(rp.role) INTO matched_roles - FROM eventstore.role_permissions rp - WHERE rp.instance_id = instanceId - AND rp.permission = perm; - - -- First try if the permission was granted thru an instance-level role - DECLARE - has_instance_permission bool; - BEGIN - SELECT true INTO has_instance_permission - FROM eventstore.instance_members im - WHERE im.role = ANY(matched_roles) - AND im.instance_id = instanceId - AND im.user_id = userId - LIMIT 1; + matched_roles TEXT[] := eventstore.find_roles(req_instance_id, perm); + BEGIN + -- First try if the permission was granted thru an instance-level role + SELECT true INTO instance_permitted + FROM eventstore.instance_members im + WHERE im.role = ANY(matched_roles) + AND im.instance_id = req_instance_id + AND im.user_id = auth_user_id + LIMIT 1; - IF has_instance_permission THEN - -- Return all organizations or only those in filter_orgs - SELECT array_agg(o.org_id) INTO org_ids - FROM eventstore.instance_orgs o - WHERE o.instance_id = instanceId - AND CASE WHEN filter_orgs != '' - THEN o.org_id IN (filter_orgs) - ELSE TRUE END; - RETURN; + org_ids := ARRAY[]::TEXT[]; + IF instance_permitted THEN + RETURN; END IF; - END; - - -- Return the organizations where permission were granted thru org-level roles - SELECT array_agg(sub.org_id) INTO org_ids - FROM ( - SELECT DISTINCT om.org_id - FROM eventstore.org_members om - WHERE om.role = ANY(matched_roles) - AND om.instance_id = instanceID - AND om.user_id = userId - ) AS sub; + instance_permitted := FALSE; + + -- Return the organizations where permission were granted thru org-level roles + SELECT array_agg(sub.org_id) INTO org_ids + FROM ( + SELECT DISTINCT om.org_id + FROM eventstore.org_members om + WHERE om.role = ANY(matched_roles) + AND om.instance_id = req_instance_id + AND om.user_id = auth_user_id + AND (filter_org IS NULL OR om.org_id = filter_org) + ) AS sub; END; - END IF; END; $$; - diff --git a/cmd/setup/53/03-permitted_projects_func.sql b/cmd/setup/53/03-permitted_projects_func.sql new file mode 100644 index 0000000000..8c17481ce8 --- /dev/null +++ b/cmd/setup/53/03-permitted_projects_func.sql @@ -0,0 +1,58 @@ +-- recreate the view to include the resource_owner +CREATE OR REPLACE VIEW eventstore.project_members AS +SELECT instance_id, aggregate_id as project_id, object_id as user_id, text_value as role, resource_owner as org_id +FROM eventstore.fields +WHERE aggregate_type = 'project' +AND object_type = 'project_member_role' +AND field_name = 'project_role'; + +DROP FUNCTION IF EXISTS eventstore.permitted_projects; + +CREATE OR REPLACE FUNCTION eventstore.permitted_projects( + req_instance_id TEXT + , auth_user_id TEXT + , system_user_perms JSONB + , perm TEXT + , filter_org TEXT + + , instance_permitted OUT BOOLEAN + , org_ids OUT TEXT[] + , project_ids OUT TEXT[] +) + LANGUAGE 'plpgsql' STABLE +AS $$ +BEGIN + -- if system user + IF system_user_perms IS NOT NULL THEN + SELECT p.instance_permitted, p.org_ids INTO instance_permitted, org_ids, project_ids + FROM eventstore.check_system_user_perms(system_user_perms, req_instance_id, perm) p; + RETURN; + END IF; + + -- if human/machine user + SELECT * FROM eventstore.permitted_orgs( + req_instance_id + , auth_user_id + , system_user_perms + , perm + , filter_org + ) INTO instance_permitted, org_ids; + IF instance_permitted THEN + RETURN; + END IF; + DECLARE + matched_roles TEXT[] := eventstore.find_roles(req_instance_id, perm); + BEGIN + -- Get the projects where permission were granted thru project-level roles + SELECT array_agg(sub.project_id) INTO project_ids + FROM ( + SELECT DISTINCT pm.project_id + FROM eventstore.project_members pm + WHERE pm.role = ANY(matched_roles) + AND pm.instance_id = req_instance_id + AND pm.user_id = auth_user_id + AND (filter_org IS NULL OR pm.org_id = filter_org) + ) AS sub; + END; +END; +$$; diff --git a/cmd/setup/integration_test/permission_test.go b/cmd/setup/integration_test/permission_test.go new file mode 100644 index 0000000000..2b0c56865f --- /dev/null +++ b/cmd/setup/integration_test/permission_test.go @@ -0,0 +1,871 @@ +//go:build integration + +package setup_test + +import ( + "encoding/json" + "testing" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/permission" + "github.com/zitadel/zitadel/internal/repository/project" +) + +func TestGetSystemPermissions(t *testing.T) { + const query = "SELECT * FROM eventstore.get_system_permissions($1, $2);" + t.Parallel() + permissions := []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project.read", "project.write"}, + }, + } + type result struct { + MemberType authz.MemberType + AggregateID string + ObjectID string + } + tests := []struct { + permm string + want []result + }{ + { + permm: "iam.read", + want: []result{ + { + MemberType: authz.MemberTypeSystem, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + }, + }, + }, + { + permm: "org.read", + want: []result{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + }, + }, + }, + { + permm: "project.write", + want: []result{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.permm, func(t *testing.T) { + t.Parallel() + rows, err := dbPool.Query(CTX, query, database.NewJSONArray(permissions), tt.permm) + require.NoError(t, err) + got, err := pgx.CollectRows(rows, pgx.RowToStructByPos[result]) + require.NoError(t, err) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestCheckSystemUserPerms(t *testing.T) { + // Use JSON because of the composite project_grants SQL type + const query = "SELECT row_to_json(eventstore.check_system_user_perms($1, $2, $3));" + t.Parallel() + type args struct { + reqInstanceID string + permissions []authz.SystemUserPermissions + permm string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "iam.read, instance permitted from system", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project.read", "project.write"}, + }, + }, + permm: "iam.read", + }, + want: `{ + "instance_permitted": true, + "org_ids": [], + "project_grants": [], + "project_ids": [] + }`, + }, + { + name: "org.read, instance permitted", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project.read", "project.write"}, + }, + }, + permm: "org.read", + }, + want: `{ + "instance_permitted": true, + "org_ids": [], + "project_grants": [], + "project_ids": [] + }`, + }, + { + name: "project.read, org ID and project ID permitted", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project_grant.read", "project_grant.write"}, + }, + }, + permm: "project.read", + }, + want: `{ + "instance_permitted": false, + "org_ids": ["orgID"], + "project_ids": ["projectID"], + "project_grants": [] + }`, + }, + { + name: "project_grant.read, project grant ID permitted", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeSystem, + Permissions: []string{"iam.read", "iam.write", "iam.policy.read"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"iam.read", "iam.write", "iam.policy.read", "org.read"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "orgID", + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "projectID", + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "projectID", + ObjectID: "grantID", + Permissions: []string{"project_grant.read", "project_grant.write"}, + }, + }, + permm: "project_grant.read", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [ + { + "project_id": "projectID", + "grant_id": "grantID" + } + ] + }`, + }, + { + name: "instance without aggregate ID", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "", + Permissions: []string{"foo.bar", "bar.foo"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "wrong instance ID", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "wrong", + Permissions: []string{"foo.bar", "bar.foo"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "permission on other instance", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeIAM, + AggregateID: "instanceID", + Permissions: []string{"bar.foo"}, + }, + { + MemberType: authz.MemberTypeIAM, + AggregateID: "wrong", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "org ID missing", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "multiple org IDs", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "Org1", + Permissions: []string{"foo.bar"}, + }, + { + MemberType: authz.MemberTypeOrganization, + AggregateID: "Org2", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": ["Org1", "Org2"], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "project ID missing", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProject, + AggregateID: "", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "multiple project IDs", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProject, + AggregateID: "P1", + Permissions: []string{"foo.bar"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: "P2", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": ["P1", "P2"], + "project_grants": [] + }`, + }, + { + name: "project grant ID missing", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "", + ObjectID: "", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [] + }`, + }, + { + name: "multiple project IDs", + args: args{ + reqInstanceID: "instanceID", + permissions: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "P1", + ObjectID: "O1", + Permissions: []string{"foo.bar"}, + }, + { + MemberType: authz.MemberTypeProjectGrant, + AggregateID: "P2", + ObjectID: "O2", + Permissions: []string{"foo.bar"}, + }, + }, + permm: "foo.bar", + }, + want: `{ + "instance_permitted": false, + "org_ids": [], + "project_ids": [], + "project_grants": [ + { + "project_id": "P1", + "grant_id": "O1" + }, + { + "project_id": "P2", + "grant_id": "O2" + } + ] + }`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rows, err := dbPool.Query(CTX, query, database.NewJSONArray(tt.args.permissions), tt.args.reqInstanceID, tt.args.permm) + require.NoError(t, err) + got, err := pgx.CollectOneRow(rows, pgx.RowTo[string]) + require.NoError(t, err) + assert.JSONEq(t, tt.want, got) + }) + } +} + +const ( + instanceID = "instanceID" + orgID = "orgID" + projectID = "projectID" +) + +func TestPermittedOrgs(t *testing.T) { + t.Parallel() + + tx, err := dbPool.Begin(CTX) + require.NoError(t, err) + defer tx.Rollback(CTX) + + // Insert a couple of deterministic field rows to test the function. + // Data will not persist, because the transaction is rolled back. + createRolePermission(t, tx, "IAM_OWNER", []string{"org.write", "org.read"}) + createRolePermission(t, tx, "ORG_OWNER", []string{"org.write", "org.read"}) + createMember(t, tx, instance.AggregateType, "instance_user") + createMember(t, tx, org.AggregateType, "org_user") + + const query = "SELECT instance_permitted, org_ids FROM eventstore.permitted_orgs($1,$2,$3,$4,$5);" + type args struct { + reqInstanceID string + authUserID string + systemUserPerms []authz.SystemUserPermissions + perm string + filterOrg *string + } + type result struct { + InstancePermitted bool + OrgIDs pgtype.FlatArray[string] + } + tests := []struct { + name string + args args + want result + }{ + { + name: "system user, instance", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeSystem, + Permissions: []string{"org.write", "org.read"}, + }}, + perm: "org.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "system user, orgs", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Permissions: []string{"org.read", "org.write", "org.policy.read", "project.read", "project.write"}, + }}, + perm: "org.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "instance member", + args: args{ + reqInstanceID: instanceID, + authUserID: "instance_user", + perm: "org.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "org member", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "org.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "org.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter wrong org", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "org.read", + filterOrg: gu.Ptr("foobar"), + }, + want: result{}, + }, + { + name: "no permission", + args: args{ + reqInstanceID: instanceID, + authUserID: "foobar", + perm: "org.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, err := tx.Query(CTX, query, tt.args.reqInstanceID, tt.args.authUserID, database.NewJSONArray(tt.args.systemUserPerms), tt.args.perm, tt.args.filterOrg) + require.NoError(t, err) + got, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[result]) + require.NoError(t, err) + assert.Equal(t, tt.want.InstancePermitted, got.InstancePermitted) + assert.ElementsMatch(t, tt.want.OrgIDs, got.OrgIDs) + }) + } +} + +func TestPermittedProjects(t *testing.T) { + t.Parallel() + + tx, err := dbPool.Begin(CTX) + require.NoError(t, err) + defer tx.Rollback(CTX) + + // Insert a couple of deterministic field rows to test the function. + // Data will not persist, because the transaction is rolled back. + createRolePermission(t, tx, "IAM_OWNER", []string{"project.write", "project.read"}) + createRolePermission(t, tx, "ORG_OWNER", []string{"project.write", "project.read"}) + createRolePermission(t, tx, "PROJECT_OWNER", []string{"project.write", "project.read"}) + createMember(t, tx, instance.AggregateType, "instance_user") + createMember(t, tx, org.AggregateType, "org_user") + createMember(t, tx, project.AggregateType, "project_user") + + const query = "SELECT instance_permitted, org_ids, project_ids FROM eventstore.permitted_projects($1,$2,$3,$4,$5);" + type args struct { + reqInstanceID string + authUserID string + systemUserPerms []authz.SystemUserPermissions + perm string + filterOrg *string + } + type result struct { + InstancePermitted bool + OrgIDs pgtype.FlatArray[string] + ProjectIDs pgtype.FlatArray[string] + } + tests := []struct { + name string + args args + want result + }{ + { + name: "system user, instance", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeSystem, + Permissions: []string{"project.write", "project.read"}, + }}, + perm: "project.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "system user, orgs", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Permissions: []string{"project.read", "project.write"}, + }}, + perm: "project.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "system user, projects", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeProject, + AggregateID: projectID, + Permissions: []string{"project.read", "project.write"}, + }}, + perm: "project.read", + }, + want: result{ + ProjectIDs: pgtype.FlatArray[string]{projectID}, + }, + }, + { + name: "system user, org and project", + args: args{ + reqInstanceID: instanceID, + systemUserPerms: []authz.SystemUserPermissions{ + { + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Permissions: []string{"project.read", "project.write"}, + }, + { + MemberType: authz.MemberTypeProject, + AggregateID: projectID, + Permissions: []string{"project.read", "project.write"}, + }, + }, + perm: "project.read", + }, + want: result{ + OrgIDs: pgtype.FlatArray[string]{orgID}, + ProjectIDs: pgtype.FlatArray[string]{projectID}, + }, + }, + { + name: "instance member", + args: args{ + reqInstanceID: instanceID, + authUserID: "instance_user", + perm: "project.read", + }, + want: result{ + InstancePermitted: true, + }, + }, + { + name: "org member", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "project.read", + }, + want: result{ + InstancePermitted: false, + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "project.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{ + InstancePermitted: false, + OrgIDs: pgtype.FlatArray[string]{orgID}, + }, + }, + { + name: "org member, filter wrong org", + args: args{ + reqInstanceID: instanceID, + authUserID: "org_user", + perm: "project.read", + filterOrg: gu.Ptr("foobar"), + }, + want: result{}, + }, + { + name: "project member", + args: args{ + reqInstanceID: instanceID, + authUserID: "project_user", + perm: "project.read", + }, + want: result{ + ProjectIDs: pgtype.FlatArray[string]{projectID}, + }, + }, + { + name: "no permission", + args: args{ + reqInstanceID: instanceID, + authUserID: "foobar", + perm: "project.read", + filterOrg: gu.Ptr(orgID), + }, + want: result{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rows, err := tx.Query(CTX, query, tt.args.reqInstanceID, tt.args.authUserID, database.NewJSONArray(tt.args.systemUserPerms), tt.args.perm, tt.args.filterOrg) + require.NoError(t, err) + got, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByPos[result]) + require.NoError(t, err) + assert.Equal(t, tt.want.InstancePermitted, got.InstancePermitted) + assert.ElementsMatch(t, tt.want.OrgIDs, got.OrgIDs) + }) + } +} + +func createRolePermission(t *testing.T, tx pgx.Tx, role string, permissions []string) { + for _, perm := range permissions { + createTestField(t, tx, instanceID, permission.AggregateType, instanceID, "role_permission", role, "permission", perm) + } +} + +func createMember(t *testing.T, tx pgx.Tx, aggregateType eventstore.AggregateType, userID string) { + var err error + switch aggregateType { + case instance.AggregateType: + createTestField(t, tx, instanceID, aggregateType, instanceID, "instance_member_role", userID, "instance_role", "IAM_OWNER") + case org.AggregateType: + createTestField(t, tx, orgID, aggregateType, orgID, "org_member_role", userID, "org_role", "ORG_OWNER") + case project.AggregateType: + createTestField(t, tx, orgID, aggregateType, orgID, "project_member_role", userID, "project_role", "PROJECT_OWNER") + default: + panic("unknown aggregate type " + aggregateType) + } + require.NoError(t, err) +} + +func createTestField(t *testing.T, tx pgx.Tx, resourceOwner string, aggregateType eventstore.AggregateType, aggregateID, objectType, objectID, fieldName string, value any) { + const query = `INSERT INTO eventstore.fields( + instance_id, resource_owner, aggregate_type, aggregate_id, object_type, object_id, field_name, value, value_must_be_unique, should_index, object_revision) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, true, 1);` + encValue, err := json.Marshal(value) + require.NoError(t, err) + _, err = tx.Exec(CTX, query, instanceID, resourceOwner, aggregateType, aggregateID, objectType, objectID, fieldName, encValue) + require.NoError(t, err) + +} diff --git a/cmd/setup/integration_test/setup_test.go b/cmd/setup/integration_test/setup_test.go new file mode 100644 index 0000000000..42b8502841 --- /dev/null +++ b/cmd/setup/integration_test/setup_test.go @@ -0,0 +1,41 @@ +// Package setup_test implements tests for procedural PostgreSQL functions, +// created in the database during Zitadel setup. +// Tests depend on `zitadel setup` being run first and therefore is run as integration tests. +// A PGX connection is used directly to the integration test database. +// This package assumes the database server available as per integration test defaults. +// See the [ConnString] constant. + +//go:build integration + +package setup_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +const ConnString = "host=localhost port=5432 user=zitadel dbname=zitadel sslmode=disable" + +var ( + CTX context.Context + dbPool *pgxpool.Pool +) + +func TestMain(m *testing.M) { + var cancel context.CancelFunc + CTX, cancel = context.WithTimeout(context.Background(), time.Second*10) + + var err error + dbPool, err = pgxpool.New(context.Background(), ConnString) + if err != nil { + panic(err) + } + exit := m.Run() + cancel() + dbPool.Close() + os.Exit(exit) +} diff --git a/internal/api/authz/context.go b/internal/api/authz/context.go index d12a1def44..ff2fa8d445 100644 --- a/internal/api/authz/context.go +++ b/internal/api/authz/context.go @@ -1,4 +1,4 @@ -//go:generate enumer -type MemberType -trimprefix MemberType -json +//go:generate enumer -type MemberType -trimprefix MemberType -json -sql package authz diff --git a/internal/api/authz/membertype_enumer.go b/internal/api/authz/membertype_enumer.go index a4275a2254..9354194660 100644 --- a/internal/api/authz/membertype_enumer.go +++ b/internal/api/authz/membertype_enumer.go @@ -1,8 +1,9 @@ -// Code generated by "enumer -type MemberType -trimprefix MemberType -json"; DO NOT EDIT. +// Code generated by "enumer -type MemberType -trimprefix MemberType -json -sql"; DO NOT EDIT. package authz import ( + "database/sql/driver" "encoding/json" "fmt" "strings" @@ -110,3 +111,33 @@ func (i *MemberType) UnmarshalJSON(data []byte) error { *i, err = MemberTypeString(s) return err } + +func (i MemberType) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *MemberType) Scan(value interface{}) error { + if value == nil { + return nil + } + + var str string + switch v := value.(type) { + case []byte: + str = string(v) + case string: + str = v + case fmt.Stringer: + str = v.String() + default: + return fmt.Errorf("invalid value of MemberType: %[1]T(%[1]v)", value) + } + + val, err := MemberTypeString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/internal/query/idp_user_link.go b/internal/query/idp_user_link.go index 99bf3c403b..7f162f235e 100644 --- a/internal/query/idp_user_link.go +++ b/internal/query/idp_user_link.go @@ -110,13 +110,14 @@ func idpLinksPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enab if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, IDPUserLinkResourceOwnerCol, domain.PermissionUserRead, SingleOrgPermissionOption(queries.Queries), OwnedRowsPermissionOption(IDPUserLinkUserIDCol), - )) + ) + return query.JoinClause(join, args...) } func (q *Queries) IDPUserLinks(ctx context.Context, queries *IDPUserLinksSearchQuery, permissionCheck domain.PermissionCheck) (idps *IDPUserLinks, err error) { diff --git a/internal/query/org.go b/internal/query/org.go index 643aec291a..dfe90ad9f8 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -97,11 +97,12 @@ func orgsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, OrgColumnID, domain_pkg.PermissionOrgRead, - )) + ) + return query.JoinClause(join, args...) } type OrgSearchQueries struct { diff --git a/internal/query/permission.go b/internal/query/permission.go index 3157430264..19e3ed984e 100644 --- a/internal/query/permission.go +++ b/internal/query/permission.go @@ -2,7 +2,6 @@ package query import ( "context" - "fmt" sq "github.com/Masterminds/squirrel" "github.com/zitadel/logging" @@ -10,41 +9,66 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" domain_pkg "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" ) const ( - // eventstore.permitted_orgs(instanceid text, userid text, system_user_perms JSONB, perm text, filter_org text) - wherePermittedOrgsExpr = "%s = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))" + // eventstore.permitted_orgs(req_instance_id text, auth_user_id text, system_user_perms JSONB, perm text, filter_org text) + joinPermittedOrgsFunction = `INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON ` + + // eventstore.permitted_projects(req_instance_id text, auth_user_id text, system_user_perms JSONB, perm text, filter_org text) + joinPermittedProjectsFunction = `INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON ` ) +// permissionClauseBuilder is used to build the SQL clause for permission checks. +// Don't use it directly, use the [PermissionClause] function with proper options instead. type permissionClauseBuilder struct { orgIDColumn Column instanceID string userID string systemPermissions []authz.SystemUserPermissions permission string - orgID string - connections []sq.Eq + + // optional fields + orgID *string + projectIDColumn *Column + connections []sq.Eq } func (b *permissionClauseBuilder) appendConnection(column string, value any) { b.connections = append(b.connections, sq.Eq{column: value}) } -func (b *permissionClauseBuilder) clauses() sq.Or { - clauses := make(sq.Or, 1, len(b.connections)+1) - clauses[0] = sq.Expr( - fmt.Sprintf(wherePermittedOrgsExpr, b.orgIDColumn.identifier()), +// joinFunction picks the correct SQL function and return the required arguments for that function. +func (b *permissionClauseBuilder) joinFunction() (sql string, args []any) { + sql = joinPermittedOrgsFunction + if b.projectIDColumn != nil { + sql = joinPermittedProjectsFunction + } + return sql, []any{ b.instanceID, b.userID, database.NewJSONArray(b.systemPermissions), b.permission, b.orgID, - ) - for _, include := range b.connections { - clauses = append(clauses, include) } - return clauses +} + +// joinConditions returns the conditions for the join, +// which are dynamic based on the provided options. +func (b *permissionClauseBuilder) joinConditions() sq.Or { + conditions := make(sq.Or, 2, len(b.connections)+3) + conditions[0] = sq.Expr("permissions.instance_permitted") + conditions[1] = sq.Expr(b.orgIDColumn.identifier() + " = ANY(permissions.org_ids)") + if b.projectIDColumn != nil { + conditions = append(conditions, + sq.Expr(b.projectIDColumn.identifier()+" = ANY(permissions.project_ids)"), + ) + } + for _, c := range b.connections { + conditions = append(conditions, c) + } + return conditions } type PermissionOption func(b *permissionClauseBuilder) @@ -52,6 +76,8 @@ type PermissionOption func(b *permissionClauseBuilder) // OwnedRowsPermissionOption allows rows to be returned of which the current user is the owner. // Even if the user does not have an explicit permission for the organization. // For example an authenticated user can always see his own user account. +// This option may be provided multiple times to allow matching with multiple columns. +// See [ConnectionPermissionOption] for more details. func OwnedRowsPermissionOption(userIDColumn Column) PermissionOption { return func(b *permissionClauseBuilder) { b.appendConnection(userIDColumn.identifier(), b.userID) @@ -59,7 +85,10 @@ func OwnedRowsPermissionOption(userIDColumn Column) PermissionOption { } // ConnectionPermissionOption allows returning of rows where the value is matched. -// Even if the user does not have an explicit permission for the organization. +// Even if the user does not have an explicit permission for the resource. +// Multiple connections may be provided. +// Each connection is applied in a OR condition, so if previous permissions are not met, +// matching rows are still returned for a later match. func ConnectionPermissionOption(column Column, value any) PermissionOption { return func(b *permissionClauseBuilder) { b.appendConnection(column.identifier(), value) @@ -70,15 +99,28 @@ func ConnectionPermissionOption(column Column, value any) PermissionOption { // returned organizations, to the one used in the requested filters. func SingleOrgPermissionOption(queries []SearchQuery) PermissionOption { return func(b *permissionClauseBuilder) { - b.orgID = findTextEqualsQuery(b.orgIDColumn, queries) + orgID, ok := findTextEqualsQuery(b.orgIDColumn, queries) + if ok { + b.orgID = &orgID + } } } -// PermissionClause sets a `WHERE` clause to query, -// which filters returned rows the current authenticated user has the requested permission to. +// WithProjectsPermissionOption sets an additional filter against the project ID column, +// allowing for project specific permissions. +func WithProjectsPermissionOption(projectIDColumn Column) PermissionOption { + return func(b *permissionClauseBuilder) { + b.projectIDColumn = &projectIDColumn + } +} + +// PermissionClause builds a `INNER JOIN` clause which can be applied to a query builder. +// It filters returned rows the current authenticated user has the requested permission to. +// See permission_example_test.go for examples. // -// Experimental: Work in progress. Currently only organization permissions are supported -func PermissionClause(ctx context.Context, orgIDCol Column, permission string, options ...PermissionOption) sq.Or { +// Experimental: Work in progress. Currently only organization and project permissions are supported +// TODO: Add support for project grants. +func PermissionClause(ctx context.Context, orgIDCol Column, permission string, options ...PermissionOption) (string, []any) { ctxData := authz.GetCtxData(ctx) b := &permissionClauseBuilder{ orgIDColumn: orgIDCol, @@ -97,10 +139,18 @@ func PermissionClause(ctx context.Context, orgIDCol Column, permission string, o "system_user_permissions", b.systemPermissions, "permission", b.permission, "org_id", b.orgID, - "overrides", b.connections, + "project_id_column", b.projectIDColumn, + "connections", b.connections, ).Debug("permitted orgs check used") - return b.clauses() + sql, args := b.joinFunction() + conditions, conditionArgs, err := b.joinConditions().ToSql() + if err != nil { + // all cases are tested, no need to return an error. + // If an error does happen, it's a bug and not a user error. + panic(zerrors.ThrowInternal(err, "PERMISSION-OoS5o", "Errors.Internal")) + } + return sql + conditions, append(args, conditionArgs...) } // PermissionV2 checks are enabled when the feature flag is set and the permission check function is not nil. diff --git a/internal/query/permission_example_test.go b/internal/query/permission_example_test.go new file mode 100644 index 0000000000..6211ad0bb2 --- /dev/null +++ b/internal/query/permission_example_test.go @@ -0,0 +1,78 @@ +package query + +import ( + "context" + "fmt" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" +) + +// ExamplePermissionClause_org shows how to use the PermissionClause function to filter +// permitted records based on the resource owner and the user's instance or organization membership. +func ExamplePermissionClause_org() { + // These variables are typically set in the middleware of Zitadel. + // They do not influence the generation of the clause, just what + // the function does in Postgres. + ctx := authz.WithInstanceID(context.Background(), "instanceID") + ctx = authz.SetCtxData(ctx, authz.CtxData{ + UserID: "userID", + }) + + join, args := PermissionClause( + ctx, + UserResourceOwnerCol, // match the resource owner column + domain.PermissionUserRead, + SingleOrgPermissionOption([]SearchQuery{ + mustSearchQuery(NewUserDisplayNameSearchQuery("zitadel", TextContains)), + mustSearchQuery(NewUserResourceOwnerSearchQuery("orgID", TextEquals)), + }), // If the request had an orgID filter, it can be used to optimize the SQL function. + OwnedRowsPermissionOption(UserIDCol), // allow user to find themselves. + ) + + sql, _, _ := sq.Select("*"). + From(userTable.identifier()). + JoinClause(join, args...). + Where(sq.Eq{ + UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), + }).ToSql() + fmt.Println(sql) + // Output: + // SELECT * FROM projections.users14 INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids) OR projections.users14.id = ?) WHERE projections.users14.instance_id = ? +} + +// ExamplePermissionClause_project shows how to use the PermissionClause function to filter +// permitted records based on the resource owner and the user's instance or organization membership. +// Additionally, it allows returning records based on the project ID and project membership. +func ExamplePermissionClause_project() { + // These variables are typically set in the middleware of Zitadel. + // They do not influence the generation of the clause, just what + // the function does in Postgres. + ctx := authz.WithInstanceID(context.Background(), "instanceID") + ctx = authz.SetCtxData(ctx, authz.CtxData{ + UserID: "userID", + }) + + join, args := PermissionClause( + ctx, + ProjectColumnResourceOwner, // match the resource owner column + "project.read", + WithProjectsPermissionOption(ProjectColumnID), + SingleOrgPermissionOption([]SearchQuery{ + mustSearchQuery(NewUserDisplayNameSearchQuery("zitadel", TextContains)), + mustSearchQuery(NewUserResourceOwnerSearchQuery("orgID", TextEquals)), + }), // If the request had an orgID filter, it can be used to optimize the SQL function. + ) + + sql, _, _ := sq.Select("*"). + From(projectsTable.identifier()). + JoinClause(join, args...). + Where(sq.Eq{ + ProjectColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + }).ToSql() + fmt.Println(sql) + // Output: + // SELECT * FROM projections.projects4 INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.projects4.resource_owner = ANY(permissions.org_ids) OR projections.projects4.id = ANY(permissions.project_ids)) WHERE projections.projects4.instance_id = ? +} diff --git a/internal/query/permission_test.go b/internal/query/permission_test.go index f6ecd94b46..24692a9406 100644 --- a/internal/query/permission_test.go +++ b/internal/query/permission_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - sq "github.com/Masterminds/squirrel" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -38,30 +38,29 @@ func TestPermissionClause(t *testing.T) { options []PermissionOption } tests := []struct { - name string - args args - wantClause sq.Or + name string + args args + wantSql string + wantArgs []any }{ { - name: "no options", + name: "org, no options", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, permission: "permission1", }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "", - ), + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), }, }, { - name: "owned rows option", + name: "org, owned rows option", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, @@ -70,20 +69,18 @@ func TestPermissionClause(t *testing.T) { OwnedRowsPermissionOption(UserIDCol), }, }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "", - ), - sq.Eq{"projections.users14.id": "userID"}, + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids) OR projections.users14.id = ?)", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), + "userID", }, }, { - name: "connection rows option", + name: "org, connection rows option", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, @@ -93,21 +90,19 @@ func TestPermissionClause(t *testing.T) { ConnectionPermissionOption(UserStateCol, "bar"), }, }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "", - ), - sq.Eq{"projections.users14.id": "userID"}, - sq.Eq{"projections.users14.state": "bar"}, + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids) OR projections.users14.id = ? OR projections.users14.state = ?)", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), + "userID", + "bar", }, }, { - name: "single org option", + name: "org, with ID", args: args{ ctx: ctx, orgIDCol: UserResourceOwnerCol, @@ -119,22 +114,62 @@ func TestPermissionClause(t *testing.T) { }), }, }, - wantClause: sq.Or{ - sq.Expr( - "projections.users14.resource_owner = ANY(eventstore.permitted_orgs(?, ?, ?, ?, ?))", - "instanceID", - "userID", - database.NewJSONArray(permissions), - "permission1", - "orgID", - ), + wantSql: "INNER JOIN eventstore.permitted_orgs(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.users14.resource_owner = ANY(permissions.org_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + gu.Ptr("orgID"), + }, + }, + { + name: "project", + args: args{ + ctx: ctx, + orgIDCol: ProjectColumnResourceOwner, + permission: "permission1", + options: []PermissionOption{ + WithProjectsPermissionOption(ProjectColumnID), + }, + }, + wantSql: "INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.projects4.resource_owner = ANY(permissions.org_ids) OR projections.projects4.id = ANY(permissions.project_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + (*string)(nil), + }, + }, + { + name: "project, single org", + args: args{ + ctx: ctx, + orgIDCol: ProjectColumnResourceOwner, + permission: "permission1", + options: []PermissionOption{ + WithProjectsPermissionOption(ProjectColumnID), + SingleOrgPermissionOption([]SearchQuery{ + mustSearchQuery(NewProjectResourceOwnerSearchQuery("orgID")), + }), + }, + }, + wantSql: "INNER JOIN eventstore.permitted_projects(?, ?, ?, ?, ?) permissions ON (permissions.instance_permitted OR projections.projects4.resource_owner = ANY(permissions.org_ids) OR projections.projects4.id = ANY(permissions.project_ids))", + wantArgs: []any{ + "instanceID", + "userID", + database.NewJSONArray(permissions), + "permission1", + gu.Ptr("orgID"), }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotClause := PermissionClause(tt.args.ctx, tt.args.orgIDCol, tt.args.permission, tt.args.options...) - assert.Equal(t, tt.wantClause, gotClause) + gotSql, gotArgs := PermissionClause(tt.args.ctx, tt.args.orgIDCol, tt.args.permission, tt.args.options...) + assert.Equal(t, tt.wantSql, gotSql) + assert.Equal(t, tt.wantArgs, gotArgs) }) } } diff --git a/internal/query/query.go b/internal/query/query.go index bd50d3c0be..e2e7f58ffc 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -149,15 +149,15 @@ func triggerBatch(ctx context.Context, handlers ...*handler.Handler) { wg.Wait() } -func findTextEqualsQuery(column Column, queries []SearchQuery) string { +func findTextEqualsQuery(column Column, queries []SearchQuery) (text string, ok bool) { for _, query := range queries { if query.Col() != column { continue } tq, ok := query.(*textQuery) if ok && tq.Compare == TextEquals { - return tq.Text + return tq.Text, true } } - return "" + return "", false } diff --git a/internal/query/session.go b/internal/query/session.go index 004f29fe81..ff0cbd8d42 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -117,7 +117,7 @@ func sessionsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enab if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, SessionColumnResourceOwner, domain.PermissionSessionRead, @@ -125,8 +125,10 @@ func sessionsPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enab OwnedRowsPermissionOption(SessionColumnCreator), // Allow if session belongs to the user OwnedRowsPermissionOption(SessionColumnUserID), + // Allow if session belongs to the same useragent ConnectionPermissionOption(SessionColumnUserAgentFingerprintID, authz.GetCtxData(ctx).AgentID), - )) + ) + return query.JoinClause(join, args...) } func (q *SessionsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { diff --git a/internal/query/user.go b/internal/query/user.go index 47694736c4..a97e3bbd14 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -136,13 +136,14 @@ func userPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, UserResourceOwnerCol, domain.PermissionUserRead, SingleOrgPermissionOption(queries.Queries), OwnedRowsPermissionOption(UserIDCol), - )) + ) + return query.JoinClause(join, args...) } type UserSearchQueries struct { diff --git a/internal/query/user_auth_method.go b/internal/query/user_auth_method.go index acf61bf0e6..fce34967cf 100644 --- a/internal/query/user_auth_method.go +++ b/internal/query/user_auth_method.go @@ -108,12 +108,13 @@ func userAuthMethodPermissionCheckV2(ctx context.Context, query sq.SelectBuilder if !enabled { return query } - return query.Where(PermissionClause( + join, args := PermissionClause( ctx, UserAuthMethodColumnResourceOwner, domain.PermissionUserRead, OwnedRowsPermissionOption(UserIDCol), - )) + ) + return query.JoinClause(join, args...) } type AuthMethod struct { From aa9ef8b49e4ce15cceb955abd2ebb3d9c90cf48d Mon Sep 17 00:00:00 2001 From: Zach Hirschtritt Date: Tue, 22 Apr 2025 05:34:02 -0400 Subject: [PATCH 009/123] fix: Auto cleanup failed Setup steps if process is killed (#9736) # Which Problems Are Solved When running a long-running Zitadel Setup, Kubernetes might decide to move a pod to a new node automatically. Currently, this puts any migrations into a broken state that an operator needs to manually run the "cleanup" command on - assuming they catch the error. The only super long running commands are typically projection pre-fill operations, which depending on the size of the event table for that projection, can take many hours - plenty of time for Kubernetes to make unexpected decisions, especially in a busy cluster. # How the Problems Are Solved This change listens on `os.Interrupt` and `syscall.SIGTERM`, cancels the current Setup context, and runs the `Cleanup` command. The logs then look something like this: ```shell ... INFO[0000] verify migration caller="/Users/zach/src/zitadel/internal/migration/migration.go:43" name=repeatable_delete_stale_org_fields INFO[0000] starting migration caller="/Users/zach/src/zitadel/internal/migration/migration.go:66" name=repeatable_delete_stale_org_fields INFO[0000] execute delete query caller="/Users/zach/src/zitadel/cmd/setup/39.go:37" instance_id=281297936179003398 migration=repeatable_delete_stale_org_fields progress=1/1 INFO[0000] verify migration caller="/Users/zach/src/zitadel/internal/migration/migration.go:43" name=repeatable_fill_fields_for_instance_domains INFO[0000] starting migration caller="/Users/zach/src/zitadel/internal/migration/migration.go:66" name=repeatable_fill_fields_for_instance_domains ----- SIGTERM signal issued ----- INFO[0000] received interrupt signal, shutting down: interrupt caller="/Users/zach/src/zitadel/cmd/setup/setup.go:121" INFO[0000] query failed caller="/Users/zach/src/zitadel/internal/eventstore/repository/sql/query.go:135" error="timeout: context already done: context canceled" DEBU[0000] filter eventstore failed caller="/Users/zach/src/zitadel/internal/eventstore/handler/v2/field_handler.go:155" error="ID=SQL-KyeAx Message=unable to filter events Parent=(timeout: context already done: context canceled)" projection=instance_domain_fields DEBU[0000] unable to rollback tx caller="/Users/zach/src/zitadel/internal/eventstore/handler/v2/field_handler.go:110" error="sql: transaction has already been committed or rolled back" projection=instance_domain_fields INFO[0000] process events failed caller="/Users/zach/src/zitadel/internal/eventstore/handler/v2/field_handler.go:72" error="ID=SQL-KyeAx Message=unable to filter events Parent=(timeout: context already done: context canceled)" projection=instance_domain_fields DEBU[0000] trigger iteration caller="/Users/zach/src/zitadel/internal/eventstore/handler/v2/field_handler.go:73" iteration=0 projection=instance_domain_fields ERRO[0000] migration failed caller="/Users/zach/src/zitadel/internal/migration/migration.go:68" error="ID=SQL-KyeAx Message=unable to filter events Parent=(timeout: context already done: context canceled)" name=repeatable_fill_fields_for_instance_domains ERRO[0000] migration finish failed caller="/Users/zach/src/zitadel/internal/migration/migration.go:71" error="context canceled" name=repeatable_fill_fields_for_instance_domains ----- Cleanup before exiting ----- INFO[0000] cleanup started caller="/Users/zach/src/zitadel/cmd/setup/cleanup.go:30" INFO[0000] cleanup migration caller="/Users/zach/src/zitadel/cmd/setup/cleanup.go:47" name=repeatable_fill_fields_for_instance_domains ``` # Additional Changes * `mustExecuteMigration` -> `executeMigration`: **must**Execute logged a Fatal error previously which calls os.Exit so no cleanup was possible. Instead, this PR returns an error and assigns it to a shared error in the Setup closure that defer can check. * `initProjections` now returns an error instead of exiting # Additional Context This behavior might be unwelcome or at least unexpected in some cases. Putting it behind a feature flag or config setting is likely a good followup. --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- cmd/setup/cleanup.go | 6 +-- cmd/setup/setup.go | 92 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/cmd/setup/cleanup.go b/cmd/setup/cleanup.go index e0a07c0a9d..69f7c72e53 100644 --- a/cmd/setup/cleanup.go +++ b/cmd/setup/cleanup.go @@ -21,14 +21,12 @@ func NewCleanup() *cobra.Command { Long: `cleans up migration if they got stuck`, Run: func(cmd *cobra.Command, args []string) { config := MustNewConfig(viper.GetViper()) - Cleanup(config) + Cleanup(cmd.Context(), config) }, } } -func Cleanup(config *Config) { - ctx := context.Background() - +func Cleanup(ctx context.Context, config *Config) { logging.Info("cleanup started") dbClient, err := database.Connect(config.Database, false) diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index fe628c8df2..f4df9fc71b 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -5,8 +5,13 @@ import ( "embed" _ "embed" "errors" + "fmt" "net/http" + "os" + "os/signal" "path" + "syscall" + "time" "github.com/jackc/pgx/v5/pgconn" "github.com/spf13/cobra" @@ -102,8 +107,35 @@ func bindForMirror(cmd *cobra.Command) error { func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) { logging.Info("setup started") - i18n.MustLoadSupportedLanguagesFromDir() + var setupErr error + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer func() { + stop() + + if setupErr == nil { + logging.Info("setup completed") + return + } + + if setupErr != nil && !errors.Is(setupErr, context.Canceled) { + // If Setup failed for some other reason than the context being cancelled, + // then this could be a fatal error we should not retry + logging.WithFields("error", setupErr).Fatal("setup failed, skipping cleanup") + return + } + + // if we're in the middle of long-running setup, run cleanup before exiting + // so if/when we're restarted we can pick up where we left off rather than + // booting into a broken state that requires manual intervention + // kubernetes will typically kill the pod after 30 seconds if the container does not exit + cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cleanupCancel() + + Cleanup(cleanupCtx, config) + }() + + i18n.MustLoadSupportedLanguagesFromDir() dbClient, err := database.Connect(config.Database, false) logging.OnError(err).Fatal("unable to connect to database") @@ -223,7 +255,10 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s52IDPTemplate6LDAP2, steps.s53InitPermittedOrgsFunction, } { - mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") + setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") + if setupErr != nil { + return + } } commands, _, _, _ := startCommandsQueries(ctx, eventstoreClient, eventstoreV4, dbClient, masterKey, config) @@ -257,7 +292,10 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) } for _, repeatableStep := range repeatableSteps { - mustExecuteMigration(ctx, eventstoreClient, repeatableStep, "unable to migrate repeatable step") + setupErr = executeMigration(ctx, eventstoreClient, repeatableStep, "unable to migrate repeatable step") + if setupErr != nil { + return + } } // These steps are executed after the repeatable steps because they add fields projections @@ -273,22 +311,25 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s43CreateFieldsDomainIndex, steps.s48Apps7SAMLConfigsLoginVersion, } { - mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") + setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") + if setupErr != nil { + return + } } // projection initialization must be done last, since the steps above might add required columns to the projections if !config.ForMirror && config.InitProjections.Enabled { - initProjections( - ctx, - eventstoreClient, - ) + setupErr = initProjections(ctx, eventstoreClient) + if setupErr != nil { + return + } } } -func mustExecuteMigration(ctx context.Context, eventstoreClient *eventstore.Eventstore, step migration.Migration, errorMsg string) { +func executeMigration(ctx context.Context, eventstoreClient *eventstore.Eventstore, step migration.Migration, errorMsg string) error { err := migration.Migrate(ctx, eventstoreClient, step) if err == nil { - return + return nil } logFields := []any{ "name", step.String(), @@ -303,7 +344,8 @@ func mustExecuteMigration(ctx context.Context, eventstoreClient *eventstore.Even "hint", pgErr.Hint, ) } - logging.WithFields(logFields...).WithError(err).Fatal(errorMsg) + logging.WithFields(logFields...).WithError(err).Error(errorMsg) + return fmt.Errorf("%s: %w", errorMsg, err) } // readStmt reads a single file from the embedded FS, @@ -508,26 +550,36 @@ func startCommandsQueries( func initProjections( ctx context.Context, eventstoreClient *eventstore.Eventstore, -) { +) error { logging.Info("init-projections is currently in beta") for _, p := range projection.Projections() { - err := migration.Migrate(ctx, eventstoreClient, p) - logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") + if err := migration.Migrate(ctx, eventstoreClient, p); err != nil { + logging.WithFields("name", p.String()).OnError(err).Error("projection migration failed") + return err + } } for _, p := range admin_handler.Projections() { - err := migration.Migrate(ctx, eventstoreClient, p) - logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") + if err := migration.Migrate(ctx, eventstoreClient, p); err != nil { + logging.WithFields("name", p.String()).OnError(err).Error("admin schema migration failed") + return err + } } for _, p := range auth_handler.Projections() { - err := migration.Migrate(ctx, eventstoreClient, p) - logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") + if err := migration.Migrate(ctx, eventstoreClient, p); err != nil { + logging.WithFields("name", p.String()).OnError(err).Error("auth schema migration failed") + return err + } } for _, p := range notify_handler.Projections() { - err := migration.Migrate(ctx, eventstoreClient, p) - logging.WithFields("name", p.String()).OnError(err).Fatal("migration failed") + if err := migration.Migrate(ctx, eventstoreClient, p); err != nil { + logging.WithFields("name", p.String()).OnError(err).Error("notification migration failed") + return err + } } + + return nil } From 56e0df67d59116bff9fe2b3e1000ad83add998d0 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 23 Apr 2025 11:21:14 +0200 Subject: [PATCH 010/123] feat: Actions V2 improvements in console (#9759) # Which Problems Are Solved This PR allows one to edit the order of Actions V2 Targets in an Execution. Editing of Targets was also added back again. # How the Problems Are Solved One of the changes is the addition of the CorrectlyTypedExecution which restricts the Grpc types a bit more to make working with them easier. Some fields may be optional in the Grpc Protobuf but in reality are always set. Typings were generally improved to make them more accurate and safer to work with. # Additional Changes Removal of the Actions V2 Feature flag as it will be enabled by default anyways. # Additional Context This pr used some advanced Angular Signals logic which is very interesting for future PR's. - Part of the tasks from #7248 --------- Co-authored-by: Max Peintner --- .../components/features/features.component.ts | 1 - .../actions-two-actions-table.component.html | 24 +- .../actions-two-actions-table.component.ts | 101 ++++++-- .../actions-two-actions.component.html | 2 +- .../actions-two-actions.component.ts | 154 +++++------ ...tions-two-add-action-dialog.component.html | 6 +- ...tions-two-add-action-dialog.component.scss | 1 + ...actions-two-add-action-dialog.component.ts | 91 +++++-- ...tions-two-add-action-target.component.html | 70 +++-- ...tions-two-add-action-target.component.scss | 24 ++ ...actions-two-add-action-target.component.ts | 245 +++++++++++------- ...tions-two-add-target-dialog.component.html | 2 +- ...actions-two-add-target-dialog.component.ts | 2 +- .../actions-two-targets-table.component.html | 4 +- .../actions-two-targets-table.component.ts | 34 ++- .../actions-two-targets.component.ts | 110 +++----- .../oidc-webkeys-inactive-table.component.ts | 2 +- .../oidc-webkeys/oidc-webkeys.component.html | 2 +- console/src/assets/i18n/bg.json | 3 +- console/src/assets/i18n/cs.json | 3 +- console/src/assets/i18n/de.json | 3 +- console/src/assets/i18n/en.json | 3 +- console/src/assets/i18n/es.json | 3 +- console/src/assets/i18n/fr.json | 3 +- console/src/assets/i18n/hu.json | 3 +- console/src/assets/i18n/id.json | 3 +- console/src/assets/i18n/it.json | 3 +- console/src/assets/i18n/ja.json | 3 +- console/src/assets/i18n/ko.json | 3 +- console/src/assets/i18n/mk.json | 3 +- console/src/assets/i18n/nl.json | 3 +- console/src/assets/i18n/pl.json | 3 +- console/src/assets/i18n/pt.json | 3 +- console/src/assets/i18n/ro.json | 3 +- console/src/assets/i18n/ru.json | 3 +- console/src/assets/i18n/sv.json | 3 +- console/src/assets/i18n/zh.json | 3 +- 37 files changed, 557 insertions(+), 375 deletions(-) diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 0f89c5e98a..d95bbdde43 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -27,7 +27,6 @@ import { LoginV2FeatureToggleComponent } from '../feature-toggle/login-v2-featur // to add a new feature, add the key here and in the FEATURE_KEYS array const FEATURE_KEYS = [ - 'actions', 'consoleUseV2UserApi', 'debugOidcParentError', 'disableUserTokenEvent', diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html index 670fe2c53b..82f04fb124 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html @@ -1,14 +1,14 @@ - +
- +
@@ -16,7 +16,7 @@ @@ -24,16 +24,9 @@ @@ -43,7 +36,7 @@ {{ 'ACTIONSTWO.EXECUTION.TABLE.CREATIONDATE' | translate }} @@ -55,7 +48,7 @@ actions matTooltip="{{ 'ACTIONS.REMOVE' | translate }}" color="warn" - (click)="$event.stopPropagation(); delete.emit(row)" + (click)="$event.stopPropagation(); delete.emit(row.execution)" mat-icon-button > @@ -69,6 +62,7 @@ class="highlight pointer" mat-row *matRowDef="let row; columns: ['condition', 'type', 'target', 'creationDate', 'actions']" + (click)="selected.emit(row.execution)" >
{{ 'ACTIONSTWO.EXECUTION.TABLE.CONDITION' | translate }} - - {{ row?.condition | condition }} + + {{ row.execution.condition | condition }} {{ 'ACTIONSTWO.EXECUTION.TABLE.TYPE' | translate }} - {{ 'ACTIONSTWO.EXECUTION.TYPES.' + row?.condition?.conditionType?.case | translate }} + {{ 'ACTIONSTWO.EXECUTION.TYPES.' + row.execution.condition.conditionType.case | translate }} {{ 'ACTIONSTWO.EXECUTION.TABLE.TARGET' | translate }}
- {{ target.name }} - - refresh - {{ condition | condition }} -
- {{ row.creationDate | timestampToDate | localizedDate: 'regular' }} + {{ row.execution.creationDate | timestampToDate | localizedDate: 'regular' }}
diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts index 6c714e2908..2d9942c406 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts @@ -1,9 +1,10 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { Observable, ReplaySubject } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { ChangeDetectionStrategy, Component, computed, effect, EventEmitter, Input, Output } from '@angular/core'; +import { combineLatestWith, Observable, ReplaySubject } from 'rxjs'; +import { filter, map, startWith } from 'rxjs/operators'; import { MatTableDataSource } from '@angular/material/table'; -import { Condition, Execution, ExecutionTargetType } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { CorrectlyTypedExecution } from '../../actions-two-add-action/actions-two-add-action-dialog.component'; @Component({ selector: 'cnsl-actions-two-actions-table', @@ -16,10 +17,13 @@ export class ActionsTwoActionsTableComponent { public readonly refresh = new EventEmitter(); @Output() - public readonly delete = new EventEmitter(); + public readonly selected = new EventEmitter(); + + @Output() + public readonly delete = new EventEmitter(); @Input({ required: true }) - public set executions(executions: Execution[] | null) { + public set executions(executions: CorrectlyTypedExecution[] | null) { this.executions$.next(executions); } @@ -28,33 +32,76 @@ export class ActionsTwoActionsTableComponent { this.targets$.next(targets); } - @Output() - public readonly selected = new EventEmitter(); + private readonly executions$ = new ReplaySubject(1); - private readonly executions$ = new ReplaySubject(1); private readonly targets$ = new ReplaySubject(1); - protected readonly dataSource$ = this.executions$.pipe( - filter(Boolean), - map((keys) => new MatTableDataSource(keys)), - ); + protected readonly dataSource = this.getDataSource(); - protected filteredTargetTypes(targets: ExecutionTargetType[]): Observable { - const targetIds = targets - .map((t) => t.type) - .filter((t): t is Extract => t.case === 'target') - .map((t) => t.value); + protected readonly loading = this.getLoading(); - return this.targets$.pipe( - filter(Boolean), - map((alltargets) => alltargets!.filter((target) => targetIds.includes(target.id))), - ); + private getDataSource() { + const executions$: Observable = this.executions$.pipe(filter(Boolean), startWith([])); + const executionsSignal = toSignal(executions$, { requireSync: true }); + + const targetsMapSignal = this.getTargetsMap(); + + const dataSignal = computed(() => { + const executions = executionsSignal(); + const targetsMap = targetsMapSignal(); + + if (targetsMap.size === 0) { + return []; + } + + return executions.map((execution) => { + const mappedTargets = execution.targets.map((target) => { + const targetType = targetsMap.get(target.type.value); + if (!targetType) { + throw new Error(`Target with id ${target.type.value} not found`); + } + return targetType; + }); + return { execution, mappedTargets }; + }); + }); + + const dataSource = new MatTableDataSource(dataSignal()); + + effect(() => { + const data = dataSignal(); + if (dataSource.data !== data) { + dataSource.data = data; + } + }); + + return dataSource; } - protected filteredIncludeConditions(targets: ExecutionTargetType[]): Condition[] { - return targets - .map((t) => t.type) - .filter((t): t is Extract => t.case === 'include') - .map(({ value }) => value); + private getTargetsMap() { + const targets$ = this.targets$.pipe(filter(Boolean), startWith([] as Target[])); + const targetsSignal = toSignal(targets$, { requireSync: true }); + + return computed(() => { + const map = new Map(); + for (const target of targetsSignal()) { + map.set(target.id, target); + } + return map; + }); + } + + private getLoading() { + const loading$ = this.executions$.pipe( + combineLatestWith(this.targets$), + map(([executions, targets]) => executions === null || targets === null), + startWith(true), + ); + + return toSignal(loading$, { requireSync: true }); + } + + protected trackTarget(_: number, target: Target) { + return target.id; } } diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html index c194466a4f..c22b03ef76 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html @@ -2,7 +2,7 @@

{{ 'ACTIONSTWO.EXECUTION.DESCRIPTION' | translate }}

(); - private readonly actionsEnabled$: Observable; - protected readonly executions$: Observable; +export class ActionsTwoActionsComponent { + protected readonly refresh$ = new Subject(); + protected readonly executions$: Observable; protected readonly targets$: Observable; constructor( private readonly actionService: ActionService, - private readonly featureService: NewFeatureService, private readonly toast: ToastService, private readonly destroyRef: DestroyRef, - private readonly router: Router, - private readonly route: ActivatedRoute, private readonly dialog: MatDialog, ) { - this.actionsEnabled$ = this.getActionsEnabled$().pipe(shareReplay({ refCount: true, bufferSize: 1 })); - this.executions$ = this.getExecutions$(this.actionsEnabled$); - this.targets$ = this.getTargets$(this.actionsEnabled$); + this.executions$ = this.getExecutions$(); + this.targets$ = this.getTargets$(); } - ngOnInit(): void { - // this also preloads - this.actionsEnabled$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(async (enabled) => { - if (enabled) { - return; - } - await this.router.navigate([], { - relativeTo: this.route, - queryParams: { - id: ORGANIZATIONS.id, - }, - queryParamsHandling: 'merge', - }); - }); - } - - private getExecutions$(actionsEnabled$: Observable) { - return this.refresh.pipe( + private getExecutions$() { + return this.refresh$.pipe( startWith(true), switchMap(() => { return this.actionService.listExecutions({}); }), - map(({ result }) => result), - catchError(async (err) => { - const actionsEnabled = await firstValueFrom(actionsEnabled$); - if (actionsEnabled) { - this.toast.showError(err); - } - return []; + map(({ result }) => result.map(correctlyTypeExecution)), + catchError((err) => { + this.toast.showError(err); + return of([]); }), ); } - private getTargets$(actionsEnabled$: Observable) { - return this.refresh.pipe( + private getTargets$() { + return this.refresh$.pipe( startWith(true), switchMap(() => { return this.actionService.listTargets({}); }), map(({ result }) => result), - catchError(async (err) => { - const actionsEnabled = await firstValueFrom(actionsEnabled$); - if (actionsEnabled) { - this.toast.showError(err); - } - return []; - }), - ); - } - - private getActionsEnabled$() { - return defer(() => this.featureService.getInstanceFeatures()).pipe( - map(({ actions }) => actions?.enabled ?? false), - timeout(1000), catchError((err) => { - if (!(err instanceof TimeoutError)) { - this.toast.showError(err); - } - return of(false); + this.toast.showError(err); + return of([]); }), ); } - public openDialog(execution?: Execution): void { - const ref = this.dialog.open(ActionTwoAddActionDialogComponent, { - width: '400px', - data: execution - ? { - execution: execution, - } - : {}, - }); + public async openDialog(execution?: CorrectlyTypedExecution): Promise { + const request$ = this.dialog + .open( + ActionTwoAddActionDialogComponent, + { + width: '400px', + data: execution + ? { + execution, + } + : {}, + }, + ) + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)); - ref.afterClosed().subscribe((request?: MessageInitShape) => { - if (request) { - this.actionService - .setExecution(request) - .then(() => { - setTimeout(() => { - this.refresh.next(true); - }, 1000); - }) - .catch((error) => { - console.error(error); - this.toast.showError(error); - }); - } - }); + const request = await lastValueFrom(request$); + if (!request) { + return; + } + + try { + await this.actionService.setExecution(request); + await new Promise((res) => setTimeout(res, 1000)); + this.refresh$.next(true); + } catch (error) { + console.error(error); + this.toast.showError(error); + } } - public async deleteExecution(execution: Execution) { + public async deleteExecution(execution: CorrectlyTypedExecution) { const deleteReq: MessageInitShape = { condition: execution.condition, targets: [], }; - await this.actionService.setExecution(deleteReq); - await new Promise((res) => setTimeout(res, 1000)); - this.refresh.next(true); + try { + await this.actionService.setExecution(deleteReq); + await new Promise((res) => setTimeout(res, 1000)); + this.refresh$.next(true); + } catch (error) { + console.error(error); + this.toast.showError(error); + } } } diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html index 9c7f78395a..cc6e989cf2 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.html @@ -14,15 +14,15 @@ *ngSwitchCase="Page.Condition" [conditionType]="typeSignal()" (back)="back()" - (continue)="conditionSignal.set($event); continue()" + (continue)="conditionSignal.set({ conditionType: { case: typeSignal(), value: $event } }); continue()" > diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss index dd597c29aa..8223e63565 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.scss @@ -7,6 +7,7 @@ .actions { display: flex; justify-content: space-between; + margin-top: 1rem; } .hide { diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts index 00281cabd7..12ae6598cc 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-dialog.component.ts @@ -7,11 +7,15 @@ import { MessageInitShape } from '@bufbuild/protobuf'; import { ActionsTwoAddActionConditionComponent, ConditionType, - ConditionTypeValue, } from './actions-two-add-action-condition/actions-two-add-action-condition.component'; import { ActionsTwoAddActionTargetComponent } from './actions-two-add-action-target/actions-two-add-action-target.component'; import { CommonModule } from '@angular/common'; -import { Execution, ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; +import { + Condition, + Execution, + ExecutionTargetType, + ExecutionTargetTypeSchema, +} from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; import { Subject } from 'rxjs'; import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; @@ -21,6 +25,41 @@ enum Page { Target, } +export type CorrectlyTypedCondition = Condition & { conditionType: Extract }; + +type CorrectlyTypedTargets = { type: Extract }; + +export type CorrectlyTypedExecution = Omit & { + condition: CorrectlyTypedCondition; + targets: CorrectlyTypedTargets[]; +}; + +export const correctlyTypeExecution = (execution: Execution): CorrectlyTypedExecution => { + if (!execution.condition?.conditionType?.case) { + throw new Error('Condition is required'); + } + const conditionType = execution.condition.conditionType; + + const condition = { + ...execution.condition, + conditionType, + }; + + return { + ...execution, + condition, + targets: execution.targets + .map(({ type }) => ({ type })) + .filter((target): target is CorrectlyTypedTargets => target.type.case === 'target'), + }; +}; + +export type ActionTwoAddActionDialogData = { + execution?: CorrectlyTypedExecution; +}; + +export type ActionTwoAddActionDialogResult = MessageInitShape; + @Component({ selector: 'cnsl-actions-two-add-action-dialog', templateUrl: './actions-two-add-action-dialog.component.html', @@ -37,45 +76,45 @@ enum Page { ], }) export class ActionTwoAddActionDialogComponent { - public Page = Page; - public page = signal(Page.Type); + protected readonly Page = Page; + protected readonly page = signal(Page.Type); - public typeSignal = signal('request'); - public conditionSignal = signal | undefined>(undefined); // TODO: fix this type - public targetSignal = signal> | undefined>(undefined); + protected readonly typeSignal = signal('request'); + protected readonly conditionSignal = signal['condition']>(undefined); + protected readonly targetsSignal = signal[]>([]); - public continueSubject = new Subject(); + protected readonly continueSubject = new Subject(); - public request = computed>(() => { + protected readonly request = computed>(() => { return { - condition: { - conditionType: { - case: this.typeSignal(), - value: this.conditionSignal() as any, // TODO: fix this type - }, - }, - targets: this.targetSignal(), + condition: this.conditionSignal(), + targets: this.targetsSignal(), }; }); + protected readonly preselectedTargetIds: string[] = []; + constructor( - public dialogRef: MatDialogRef>, - @Inject(MAT_DIALOG_DATA) protected readonly data: { execution?: Execution }, + protected readonly dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) protected readonly data: ActionTwoAddActionDialogData, ) { - if (data?.execution) { - this.typeSignal.set(data.execution.condition?.conditionType.case ?? 'request'); - this.conditionSignal.set((data.execution.condition?.conditionType as any)?.value ?? undefined); - this.targetSignal.set(data.execution.targets ?? []); - - this.page.set(Page.Target); // Set the initial page based on the provided execution data - } - effect(() => { const currentPage = this.page(); if (currentPage === Page.Target) { this.continueSubject.next(); // Trigger the Subject to request condition form when the page changes to "Target" } }); + + if (!data?.execution) { + return; + } + + this.targetsSignal.set(data.execution.targets); + this.typeSignal.set(data.execution.condition.conditionType.case); + this.conditionSignal.set(data.execution.condition); + this.preselectedTargetIds = data.execution.targets.map((target) => target.type.value); + + this.page.set(Page.Target); // Set the initial page based on the provided execution data } public continue() { diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html index a8503c71be..422ed7991e 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html @@ -1,37 +1,71 @@ -
+

{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.DESCRIPTION' | translate }}

{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.TARGET.DESCRIPTION' | translate }} - - + + + - + {{ target.name }} - + - - - - - - - - - - - - + + + + + + + + + + + + + + + +
Reorder + + Name + {{ row.name }} + + + + +
-
diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss index 776c535f1a..deff15c680 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.scss @@ -10,3 +10,27 @@ font: 1; } } + +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.cdk-drop-list-dragging .mat-row:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +.drag-row { + backdrop-filter: blur(10px); +} diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts index bdcfc54a3d..e04368f8f4 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts @@ -1,10 +1,20 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + EventEmitter, + Input, + Output, + signal, + Signal, +} from '@angular/core'; import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { MatButtonModule } from '@angular/material/button'; import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { Observable, catchError, defer, map, of, shareReplay, ReplaySubject, combineLatestWith } from 'rxjs'; +import { ReplaySubject, switchMap } from 'rxjs'; import { MatRadioModule } from '@angular/material/radio'; import { ActionService } from 'src/app/services/action.service'; import { ToastService } from 'src/app/services/toast.service'; @@ -13,15 +23,18 @@ import { InputModule } from 'src/app/modules/input/input.module'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MessageInitShape } from '@bufbuild/protobuf'; import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; -import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; -import { Condition, ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; +import { ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; import { MatSelectModule } from '@angular/material/select'; -import { atLeastOneFieldValidator } from 'src/app/modules/form-field/validators/validators'; import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module'; - -export type TargetInit = NonNullable< - NonNullable['targets']> ->[number]['type']; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { startWith } from 'rxjs/operators'; +import { TypeSafeCellDefModule } from 'src/app/directives/type-safe-cell-def/type-safe-cell-def.module'; +import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { minArrayLengthValidator } from '../../../form-field/validators/validators'; +import { ProjectRoleChipModule } from '../../../project-role-chip/project-role-chip.module'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TableActionsModule } from '../../../table-actions/table-actions.module'; @Component({ standalone: true, @@ -42,114 +55,172 @@ export type TargetInit = NonNullable< MatButtonModule, MatProgressSpinnerModule, MatSelectModule, + MatTableModule, + TypeSafeCellDefModule, + CdkDrag, + CdkDropList, + ProjectRoleChipModule, + MatTooltipModule, + TableActionsModule, ], }) export class ActionsTwoAddActionTargetComponent { - protected readonly targetForm = this.buildActionTargetForm(); + @Input() public hideBackButton = false; + @Input() + public set preselectedTargetIds(preselectedTargetIds: string[]) { + this.preselectedTargetIds$.next(preselectedTargetIds); + } @Output() public readonly back = new EventEmitter(); @Output() public readonly continue = new EventEmitter[]>(); - @Input() public hideBackButton = false; - @Input() set selectedCondition(selectedCondition: Condition | undefined) { - this.selectedCondition$.next(selectedCondition); - } - private readonly selectedCondition$ = new ReplaySubject(1); + private readonly preselectedTargetIds$ = new ReplaySubject(1); - protected readonly executionTargets$: Observable; - protected readonly executionConditions$: Observable; + protected readonly form: ReturnType; + protected readonly targets: ReturnType; + private readonly selectedTargetIds: Signal; + protected readonly selectableTargets: Signal; + protected readonly dataSource: MatTableDataSource; constructor( private readonly fb: FormBuilder, private readonly actionService: ActionService, private readonly toast: ToastService, ) { - this.executionTargets$ = this.listExecutionTargets().pipe(shareReplay({ refCount: true, bufferSize: 1 })); - this.executionConditions$ = this.listExecutionConditions().pipe(shareReplay({ refCount: true, bufferSize: 1 })); + this.form = this.buildForm(); + this.targets = this.listTargets(); + + this.selectedTargetIds = this.getSelectedTargetIds(this.form); + this.selectableTargets = this.getSelectableTargets(this.targets, this.selectedTargetIds); + this.dataSource = this.getDataSource(this.targets, this.selectedTargetIds); } - private buildActionTargetForm() { - return this.fb.group( - { - target: new FormControl(null, { validators: [] }), - executionConditions: new FormControl([], { validators: [] }), - }, - { - validators: atLeastOneFieldValidator(['target', 'executionConditions']), - }, - ); + private buildForm() { + const preselectedTargetIds = toSignal(this.preselectedTargetIds$, { initialValue: [] as string[] }); + + return computed(() => { + return this.fb.group({ + autocomplete: new FormControl('', { nonNullable: true }), + selectedTargetIds: new FormControl(preselectedTargetIds(), { + nonNullable: true, + validators: [minArrayLengthValidator(1)], + }), + }); + }); } - private listExecutionTargets() { - return defer(() => this.actionService.listTargets({})).pipe( - map(({ result }) => result.filter(this.targetHasDetailsAndConfig)), - catchError((error) => { + private listTargets() { + const targetsSignal = signal({ state: 'loading' as 'loading' | 'loaded', targets: new Map() }); + + this.actionService + .listTargets({}) + .then(({ result }) => { + const targets = result.reduce((acc, target) => { + acc.set(target.id, target); + return acc; + }, new Map()); + + targetsSignal.set({ state: 'loaded', targets }); + }) + .catch((error) => { this.toast.showError(error); - return of([]); + }); + + return computed(targetsSignal); + } + + private getSelectedTargetIds(form: typeof this.form) { + const selectedTargetIds$ = toObservable(form).pipe( + startWith(form()), + switchMap((form) => { + const { selectedTargetIds } = form.controls; + return selectedTargetIds.valueChanges.pipe(startWith(selectedTargetIds.value)); }), ); + return toSignal(selectedTargetIds$, { requireSync: true }); } - private listExecutionConditions(): Observable { - const selectedConditionJson$ = this.selectedCondition$.pipe(map((c) => JSON.stringify(c))); - - return defer(() => this.actionService.listExecutions({})).pipe( - combineLatestWith(selectedConditionJson$), - map(([executions, selectedConditionJson]) => - executions.result.map((e) => e?.condition).filter(this.conditionIsDefinedAndNotCurrentOne(selectedConditionJson)), - ), - - catchError((error) => { - this.toast.showError(error); - return of([]); - }), - ); - } - - private conditionIsDefinedAndNotCurrentOne(selectedConditionJson?: string) { - return (condition?: Condition): condition is Condition => { - if (!condition) { - // condition is undefined so it is not of type Condition - return false; + private getSelectableTargets(targets: typeof this.targets, selectedTargetIds: Signal) { + return computed(() => { + const targetsCopy = new Map(targets().targets); + for (const selectedTargetId of selectedTargetIds()) { + targetsCopy.delete(selectedTargetId); } - if (!selectedConditionJson) { - // condition is defined, and we don't have a selectedCondition so we can return all conditions - return true; - } - // we only return conditions that are not the same as the selectedCondition - return JSON.stringify(condition) !== selectedConditionJson; - }; + return Array.from(targetsCopy.values()); + }); } - private targetHasDetailsAndConfig(target: Target): target is Target { - return !!target.id && !!target.id; + private getDataSource(targetsSignal: typeof this.targets, selectedTargetIdsSignal: Signal) { + const selectedTargets = computed(() => { + // get this out of the loop so angular can track this dependency + // even if targets is empty + const { targets, state } = targetsSignal(); + const selectedTargetIds = selectedTargetIdsSignal(); + + if (state === 'loading') { + return []; + } + + return selectedTargetIds.map((id) => { + const target = targets.get(id); + if (!target) { + throw new Error(`Target with id ${id} not found`); + } + return target; + }); + }); + + const dataSource = new MatTableDataSource(selectedTargets()); + effect(() => { + dataSource.data = selectedTargets(); + }); + + return dataSource; + } + + protected addTarget(target: Target) { + const { selectedTargetIds } = this.form().controls; + selectedTargetIds.setValue([target.id, ...selectedTargetIds.value]); + this.form().controls.autocomplete.setValue(''); + } + + protected removeTarget(index: number) { + const { selectedTargetIds } = this.form().controls; + const data = [...selectedTargetIds.value]; + data.splice(index, 1); + selectedTargetIds.setValue(data); + } + + protected drop(event: CdkDragDrop) { + const { selectedTargetIds } = this.form().controls; + + const data = [...selectedTargetIds.value]; + moveItemInArray(data, event.previousIndex, event.currentIndex); + selectedTargetIds.setValue(data); + } + + protected handleEnter(event: Event) { + const selectableTargets = this.selectableTargets(); + if (selectableTargets.length !== 1) { + return; + } + + event.preventDefault(); + this.addTarget(selectableTargets[0]); } protected submit() { - const { target, executionConditions } = this.targetForm.getRawValue(); + const selectedTargets = this.selectedTargetIds().map((value) => ({ + type: { + case: 'target' as const, + value, + }, + })); - let valueToEmit: MessageInitShape[] = target - ? [ - { - type: { - case: 'target', - value: target.id, - }, - }, - ] - : []; + this.continue.emit(selectedTargets); + } - const includeConditions: MessageInitShape[] = executionConditions - ? executionConditions.map((condition) => ({ - type: { - case: 'include', - value: condition, - }, - })) - : []; - - valueToEmit = [...valueToEmit, ...includeConditions]; - - this.continue.emit(valueToEmit); + protected trackTarget(_: number, target: Target) { + return target.id; } } diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html index 496be167df..37d4f89dd0 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html @@ -59,7 +59,7 @@ {{ 'ACTIONS.CANCEL' | translate }} diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts index b9c9c64853..7d3ad0e86c 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.ts @@ -44,7 +44,7 @@ export class ActionTwoAddTargetDialogComponent { ActionTwoAddTargetDialogComponent, MessageInitShape >, - @Inject(MAT_DIALOG_DATA) private readonly data: { target?: Target }, + @Inject(MAT_DIALOG_DATA) public readonly data: { target?: Target }, ) { this.targetForm = this.buildTargetForm(); diff --git a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html index 17a73304b0..1cac09f1e4 100644 --- a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html +++ b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets-table/actions-two-targets-table.component.html @@ -1,9 +1,9 @@ - +
- +
# Which Problems Are Solved Removed the scopes/claims that were not used. # How the Problems Are Solved Made small changes in readme that fixes it. Signed-off-by: RAJAT SINGH Co-authored-by: RAJAT SINGH --- docs/docs/apis/openidoauth/claims.md | 1 - docs/docs/apis/openidoauth/scopes.md | 1 - 2 files changed, 2 deletions(-) diff --git a/docs/docs/apis/openidoauth/claims.md b/docs/docs/apis/openidoauth/claims.md index 4129806aef..b7424aaf1d 100644 --- a/docs/docs/apis/openidoauth/claims.md +++ b/docs/docs/apis/openidoauth/claims.md @@ -110,7 +110,6 @@ ZITADEL reserves some claims to assert certain data. Please check out the [reser | urn:zitadel:iam:org:domain:primary:\{domainname} | `{"urn:zitadel:iam:org:domain:primary": "acme.ch"}` | This claim represents the primary domain of the organization the user belongs to. | | urn:zitadel:iam:org:project:roles | `{"urn:zitadel:iam:org:project:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on the current project (where your client belongs to). | | urn:zitadel:iam:org:project:\{projectid}:roles | `{"urn:zitadel:iam:org:project:id3:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on a specific project. | -| urn:zitadel:iam:roles:\{rolename} | TBA | TBA | | urn:zitadel:iam:user:metadata | `{"urn:zitadel:iam:user:metadata": [ {"key": "VmFsdWU=" } ] }` | The metadata claim will include all metadata of a user. The values are base64 encoded. | | urn:zitadel:iam:user:resourceowner:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the id of the resource owner organisation of the user. | | urn:zitadel:iam:user:resourceowner:name | `{"urn:zitadel:iam:user:resourceowner:name": "ACME"}` | This claim represents the name of the resource owner organisation of the user. | diff --git a/docs/docs/apis/openidoauth/scopes.md b/docs/docs/apis/openidoauth/scopes.md index 263d888f31..86f9769cab 100644 --- a/docs/docs/apis/openidoauth/scopes.md +++ b/docs/docs/apis/openidoauth/scopes.md @@ -30,7 +30,6 @@ In addition to the standard compliant scopes we utilize the following scopes. | `urn:zitadel:iam:org:projects:roles` | `urn:zitadel:iam:org:projects:roles` | By using this scope a client can request the claim `urn:zitadel:iam:org:project:{projectid}:roles` to be asserted for each requested project. All projects of the token audience, requested by the `urn:zitadel:iam:org:project:id:{projectid}:aud` scopes will be used. | | `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. | | `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed | -| `urn:zitadel:iam:role:{rolename}` | | | | `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles.[^1] | | `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access token | | `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project ID will be added to the audience of the access token | From 44651b6e8db0d2a33e0f035dc072f62dd0c1a40c Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 24 Apr 2025 09:01:01 +0200 Subject: [PATCH 013/123] docs: improve readability of idps callback (#9793) This PR improves the readability of the difference in the IDP callback of the new V2 login compared to the legacy login. --- .../docs/guides/integrate/login-ui/external-login.mdx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/docs/guides/integrate/login-ui/external-login.mdx b/docs/docs/guides/integrate/login-ui/external-login.mdx index 3b3c47cf18..6775d2cb3b 100644 --- a/docs/docs/guides/integrate/login-ui/external-login.mdx +++ b/docs/docs/guides/integrate/login-ui/external-login.mdx @@ -16,6 +16,7 @@ ZITADEL will handle as much as possible from the authentication flow with the ex This requires you to initiate the flow with your desired provider. Send the following two URLs in the request body: + 1. SuccessURL: Page that should be shown when the login was successful 2. ErrorURL: Page that should be shown when an error happens during the authentication @@ -63,6 +64,10 @@ https://accounts.google.com/o/oauth2/v2/auth?client_id=Test&prompt=select_accoun After the user has successfully authenticated, a redirect to the ZITADEL backend /idps/callback will automatically be performed. +:::warning +Note that the redirect URL is `https://{YOUR-DOMAIN}/idps/callback` when using the new V2 hosted login compared to the V1 hosted login, which was `https://{YOUR-DOMAIN}/ui/login/login/externalidp/callback`. +::: + ## Get Provider Information ZITADEL will take the information of the provider. After this, a redirect will be made to either the success page in case of a successful login or to the error page in case of a failure will be performed. In the parameters, you will provide the IDP intentID, a token, and optionally, if a user could be found, a user ID. @@ -71,6 +76,7 @@ To get the information of the provider, make a request to ZITADEL. [Retrieve Identity Provider Intent Documentation](/docs/apis/resources/user_service_v2/user-service-retrieve-identity-provider-intent) ### Request + ```bash curl --request POST \ --url https://$ZITADEL_DOMAIN/v2/idp_intents/$INTENT_ID \ @@ -115,7 +121,9 @@ curl --request POST \ ``` ## Handle Provider Information + After successfully authenticating using your identity provider, you have three possible options. + 1. Login 2. Register user 3. Add social login to existing user @@ -127,6 +135,7 @@ Create a new session and include the IDP intent ID and the token in the checks. This check requires that the previous step ended on the successful page and didn't’t result in an error. #### Request + ```bash curl --request POST \ --url https://$ZITADEL_DOMAIN/v2/sessions \ @@ -158,6 +167,7 @@ The display name is used to list the linkings on the users. [Create User API Documentation](/docs/apis/resources/user_service_v2/user-service-add-human-user) #### Request + ```bash curl --request POST \ --url https://$ZITADEL_DOMAIN/v2/users/human \ @@ -196,6 +206,7 @@ If you want to link/connect to an existing account you can perform the add ident [Add IDP Link to existing user documentation](/docs/apis/resources/user_service_v2/user-service-add-idp-link) #### Request + ```bash curl --request POST \ --url https://$ZITADEL_DOMAIN/v2/users/users/218385419895570689/links \ From 257bef974a6d227e50142b3895b2e46cd42051f5 Mon Sep 17 00:00:00 2001 From: Stygmates Date: Thu, 24 Apr 2025 11:56:52 +0200 Subject: [PATCH 014/123] fix: text buttons overflow in login page (#9637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved The text of some of the buttons in the login page overflows in some languages ![image](https://github.com/user-attachments/assets/ef3d3bfe-8966-4be5-8d3b-3b0b72ce5e49) # How the Problems Are Solved Updated the css to set the overflow to hidden and text-overflow to ellipsis, this is the simplest fix I could come up with, if you have a better alternative feel free to tell me what you would prefer 🙏 ![image](https://github.com/user-attachments/assets/cdfa1f7b-535a-419d-ba9d-a57ec332d976) # Additional Changes None # Additional Context I couldn't test the following case locally since I had trouble setting up a SMTP provider locally, but the class affected by my change should also target this case, if someone could test it before merging it :pray:: ![315957139-6a630056-82b9-42cd-85a6-8819f2e1873b](https://github.com/user-attachments/assets/f6860db3-d6a0-4e4d-b9e6-0b1968145047) - Closes #7619 Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- .../resources/themes/scss/styles/button/button_base.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/api/ui/login/static/resources/themes/scss/styles/button/button_base.scss b/internal/api/ui/login/static/resources/themes/scss/styles/button/button_base.scss index dd53dceb79..aeeeba541c 100644 --- a/internal/api/ui/login/static/resources/themes/scss/styles/button/button_base.scss +++ b/internal/api/ui/login/static/resources/themes/scss/styles/button/button_base.scss @@ -39,7 +39,9 @@ $lgn-icon-button-line-height: 40px !default; padding: $lgn-button-padding; border-radius: $lgn-button-border-radius; - overflow: visible; + overflow: hidden; + text-overflow: ellipsis; + transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1); &[disabled] { From 106e360c195f37400025b1549480654cb9e12268 Mon Sep 17 00:00:00 2001 From: Michael Sacher <144212572+msceex@users.noreply.github.com> Date: Fri, 25 Apr 2025 08:45:39 +0200 Subject: [PATCH 015/123] docs(adopters): Clean Energy Exchange AG (#9686) doc: ADOPTERS.md ceex # Which Problems Are Solved 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 #xxx - Discussion #xxx - Follow-up for PR #xxx - https://discord.com/channels/xxx/xxx --- ADOPTERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ADOPTERS.md b/ADOPTERS.md index 0573099cf9..876d984347 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -23,6 +23,7 @@ If you are using Zitadel, please consider adding yourself as a user with a quick | OpenAIP | [@openaip](https://github.com/openAIP) | Using Zitadel Cloud for everything related to user authentication. | | Smat.io | [@smatio](https://github.com/smatio) - [@lukasver](https://github.com/lukasver) | Zitadel for authentication in cloud applications while offering B2B portfolio management solutions for professional investors | | roclub GmbH | [@holgerson97](https://github.com/holgerson97) | Roclub builds a telehealth application to enable remote MRI/CT examinations. | +| CEEX AG | [@cleanenergyexchange](https://github.com/cleanenergyexchange) | Using Zitadel cloud for our SaaS products that support the sustainabel energy transistion | | Organization Name | contact@example.com | Description of how they use Zitadel | | Individual Name | contact@example.com | Description of how they use Zitadel | From 4ffd4ef38126c73377bae1ba0508a45366d814e6 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 25 Apr 2025 09:12:42 +0200 Subject: [PATCH 016/123] fix(actions): handle empty deny list correctly (#9753) # Which Problems Are Solved A customer reached out that after an upgrade, actions would always fail with the error "host is denied" when calling an external API. This is due to a security fix (https://github.com/zitadel/zitadel/security/advisories/GHSA-6cf5-w9h3-4rqv), where a DNS lookup was added to check whether the host name resolves to a denied IP or subnet. If the lookup fails due to the internal DNS setup, the action fails as well. Additionally, the lookup was also performed when the deny list was empty. # How the Problems Are Solved - Prevent DNS lookup when deny list is empty - Properly initiate deny list and prevent empty entries # Additional Changes - Log the reason for blocked address (domain, IP, subnet) # Additional Context - reported by a customer - needs backport to 2.70.x, 2.71.x and 3.0.0 rc --- cmd/start/config.go | 4 ++- internal/actions/http_module.go | 20 +++++++-------- internal/actions/http_module_config.go | 35 ++++++++++++++++++++++---- internal/actions/http_module_test.go | 31 ++++++++++++++++------- 4 files changed, 65 insertions(+), 25 deletions(-) diff --git a/cmd/start/config.go b/cmd/start/config.go index e973c40479..78b6f0afe0 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -128,7 +128,9 @@ func MustNewConfig(v *viper.Viper) *Config { logging.OnError(err).Fatal("unable to set profiler") id.Configure(config.Machine) - actions.SetHTTPConfig(&config.Actions.HTTP) + if config.Actions != nil { + actions.SetHTTPConfig(&config.Actions.HTTP) + } // Copy the global role permissions mappings to the instance until we allow instance-level configuration over the API. config.DefaultInstance.RolePermissionMappings = config.InternalAuthZ.RolePermissionMappings diff --git a/internal/actions/http_module.go b/internal/actions/http_module.go index 2f9d09932c..db7253428d 100644 --- a/internal/actions/http_module.go +++ b/internal/actions/http_module.go @@ -176,16 +176,16 @@ type transport struct { } func (t *transport) RoundTrip(req *http.Request) (*http.Response, error) { - if httpConfig == nil { + if httpConfig == nil || len(httpConfig.DenyList) == 0 { return http.DefaultTransport.RoundTrip(req) } - if t.isHostBlocked(httpConfig.DenyList, req.URL) { - return nil, zerrors.ThrowInvalidArgument(nil, "ACTIO-N72d0", "host is denied") + if err := t.isHostBlocked(httpConfig.DenyList, req.URL); err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "ACTIO-N72d0", "host is denied") } return http.DefaultTransport.RoundTrip(req) } -func (t *transport) isHostBlocked(denyList []AddressChecker, address *url.URL) bool { +func (t *transport) isHostBlocked(denyList []AddressChecker, address *url.URL) error { host := address.Hostname() ip := net.ParseIP(host) ips := []net.IP{ip} @@ -194,17 +194,17 @@ func (t *transport) isHostBlocked(denyList []AddressChecker, address *url.URL) b var err error ips, err = t.lookup(host) if err != nil { - return true + return zerrors.ThrowInternal(err, "ACTIO-4m9s2", "lookup failed") } } - for _, blocked := range denyList { - if blocked.Matches(ips, host) { - return true + for _, denied := range denyList { + if err := denied.IsDenied(ips, host); err != nil { + return err } } - return false + return nil } type AddressChecker interface { - Matches([]net.IP, string) bool + IsDenied([]net.IP, string) error } diff --git a/internal/actions/http_module_config.go b/internal/actions/http_module_config.go index d1b965814e..eaab9e754e 100644 --- a/internal/actions/http_module_config.go +++ b/internal/actions/http_module_config.go @@ -1,6 +1,8 @@ package actions import ( + "errors" + "fmt" "net" "reflect" "strings" @@ -60,6 +62,9 @@ func HTTPConfigDecodeHook(from, to reflect.Value) (interface{}, error) { } func NewHostChecker(entry string) (AddressChecker, error) { + if entry == "" { + return nil, nil + } _, network, err := net.ParseCIDR(entry) if err == nil { return &HostChecker{Net: network}, nil @@ -76,19 +81,39 @@ type HostChecker struct { Domain string } -func (c *HostChecker) Matches(ips []net.IP, address string) bool { +type AddressDeniedError struct { + deniedBy string +} + +func NewAddressDeniedError(deniedBy string) *AddressDeniedError { + return &AddressDeniedError{deniedBy: deniedBy} +} + +func (e *AddressDeniedError) Error() string { + return fmt.Sprintf("address is denied by '%s'", e.deniedBy) +} + +func (e *AddressDeniedError) Is(target error) bool { + var addressDeniedErr *AddressDeniedError + if !errors.As(target, &addressDeniedErr) { + return false + } + return e.deniedBy == addressDeniedErr.deniedBy +} + +func (c *HostChecker) IsDenied(ips []net.IP, address string) error { // if the address matches the domain, no additional checks as needed if c.Domain == address { - return true + return NewAddressDeniedError(c.Domain) } // otherwise we need to check on ips (incl. the resolved ips of the host) for _, ip := range ips { if c.Net != nil && c.Net.Contains(ip) { - return true + return NewAddressDeniedError(c.Net.String()) } if c.IP != nil && c.IP.Equal(ip) { - return true + return NewAddressDeniedError(c.IP.String()) } } - return false + return nil } diff --git a/internal/actions/http_module_test.go b/internal/actions/http_module_test.go index 7a1f8d7816..50a007feeb 100644 --- a/internal/actions/http_module_test.go +++ b/internal/actions/http_module_test.go @@ -3,6 +3,7 @@ package actions import ( "bytes" "context" + "errors" "io" "net" "net/http" @@ -11,6 +12,7 @@ import ( "testing" "github.com/dop251/goja" + "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/logstore/record" @@ -34,21 +36,21 @@ func Test_isHostBlocked(t *testing.T) { name string fields fields args args - want bool + want error }{ { name: "in range", args: args{ address: mustNewURL(t, "https://192.168.5.4/hodor"), }, - want: true, + want: NewAddressDeniedError("192.168.5.0/24"), }, { name: "exact ip", args: args{ address: mustNewURL(t, "http://127.0.0.1:8080/hodor"), }, - want: true, + want: NewAddressDeniedError("127.0.0.1"), }, { name: "address match", @@ -60,7 +62,7 @@ func Test_isHostBlocked(t *testing.T) { args: args{ address: mustNewURL(t, "https://test.com:42/hodor"), }, - want: true, + want: NewAddressDeniedError("test.com"), }, { name: "address not match", @@ -72,7 +74,7 @@ func Test_isHostBlocked(t *testing.T) { args: args{ address: mustNewURL(t, "https://test2.com/hodor"), }, - want: false, + want: nil, }, { name: "looked up ip matches", @@ -84,7 +86,19 @@ func Test_isHostBlocked(t *testing.T) { args: args{ address: mustNewURL(t, "https://test2.com/hodor"), }, - want: true, + want: NewAddressDeniedError("127.0.0.1"), + }, + { + name: "looked up failure", + fields: fields{ + lookup: func(host string) ([]net.IP, error) { + return nil, errors.New("some error") + }, + }, + args: args{ + address: mustNewURL(t, "https://test2.com/hodor"), + }, + want: zerrors.ThrowInternal(nil, "ACTIO-4m9s2", "lookup failed"), }, } for _, tt := range tests { @@ -92,9 +106,8 @@ func Test_isHostBlocked(t *testing.T) { trans := &transport{ lookup: tt.fields.lookup, } - if got := trans.isHostBlocked(denyList, tt.args.address); got != tt.want { - t.Errorf("isHostBlocked() = %v, want %v", got, tt.want) - } + got := trans.isHostBlocked(denyList, tt.args.address) + assert.ErrorIs(t, got, tt.want) }) } } From 65bb559bbec05cdb98539f6583766d653c916145 Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Fri, 25 Apr 2025 14:04:29 +0200 Subject: [PATCH 017/123] docs(API_DESIGN.md): adding guidlines around API returns when multiple resources created (#9797) # Which Problems Are Solved Updating API_Design.md to include guidelines to specify all created resources created from an API call # How the Problems Are Solved This makes things clearer to the user if everything requested was actually created and helps with testing. See https://github.com/zitadel/zitadel/pull/9352 # Additional Context - Related https://github.com/zitadel/zitadel/issues/6305 - Related https://github.com/zitadel/zitadel/pull/9352 --------- Co-authored-by: Livio Spring --- API_DESIGN.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/API_DESIGN.md b/API_DESIGN.md index 7df13d6588..ea37df5a24 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -56,6 +56,10 @@ Provide clear and concise documentation for the API. Do not rely on implicit fallbacks or defaults if the client does not provide certain parameters. Only use defaults if they are explicitly documented, such as returning a result set for the whole instance if no filter is provided. +Some API calls may create multiple resources such as in the case of `zitadel.org.v2.AddOrganization`, where you can create an organization AND multiple users as admin. +In such cases the response should include **ALL** created resources and their ids. This removes any ambiguity from the users perspective whether or not +the additional resources were created and it also helps in testing. + ### Naming Conventions Names of resources, fields and methods MUST be descriptive and consistent. @@ -371,4 +375,4 @@ message VerifyEmailRequest{ ]; } -``` \ No newline at end of file +``` From 84628671bd207a6806667cd266b87cb4771da1fa Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 28 Apr 2025 11:02:33 +0200 Subject: [PATCH 018/123] chore: only download release relevant artifacts (#9808) # Which Problems Are Solved https://github.com/zitadel/zitadel/pull/9765 fixed an issue for with actions cache service. The PR updated the push action, which now also provides a build summary. The "release" step tries to download all artifacts, which now fails: https://github.com/zitadel/zitadel/actions/runs/14660464768/job/41145285454 # How the Problems Are Solved Only download relevant artifacts, which are published as part of the release. # Additional Changes None # Additional Context None --- .github/workflows/version.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/version.yml b/.github/workflows/version.yml index cf11e944f8..063f6956a5 100644 --- a/.github/workflows/version.yml +++ b/.github/workflows/version.yml @@ -32,6 +32,7 @@ jobs: if: ${{ !inputs.dry_run }} with: path: .artifacts + pattern: "{checksums.txt,zitadel-*}" - name: Semantic Release uses: cycjimmy/semantic-release-action@v4 From b8ba7bd5badd8b9ee43316465ae5748e6fab0f50 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Mon, 28 Apr 2025 11:24:50 +0200 Subject: [PATCH 019/123] fix: remove action feature flag and include execution (#9727) # Which Problems Are Solved Actions v2 is not a feature flag anymore, include functionality on executions is not used and json tags of proto messages are handled incorrectly. # How the Problems Are Solved - Remove actions from the feature flags on system and instance level - Remove include type on executions, only in the API, later maybe in the handling logic as well - Use protojson in request and response handling of actions v2 # Additional Changes - Correct integration tests for request and response handling - Use json.RawMessage for events, so that the event payload is not base64 encoded - Added separate context for async webhook calls, that executions are not cancelled when called async # Additional Context Related to #9759 Closes #9710 --------- Co-authored-by: Livio Spring --- .../guides/integrate/actions/testing-event.md | 9 +- .../actions/testing-request-manipulation.md | 25 ++- .../actions/testing-request-signature.md | 5 + .../integrate/actions/testing-request.md | 5 + .../actions/testing-response-manipulation.md | 48 ++++- .../integrate/actions/testing-response.md | 5 + docs/docs/guides/integrate/actions/usage.md | 12 +- internal/api/grpc/action/v2beta/execution.go | 54 +----- .../integration_test/execution_target_test.go | 99 +++++----- .../v2beta/integration_test/execution_test.go | 172 +++--------------- .../v2beta/integration_test/query_test.go | 145 ++++++++------- .../v2beta/integration_test/server_test.go | 46 ----- .../v2beta/integration_test/target_test.go | 3 - internal/api/grpc/action/v2beta/query.go | 24 +-- internal/api/grpc/action/v2beta/server.go | 10 - internal/api/grpc/action/v2beta/target.go | 9 - internal/api/grpc/feature/v2/converter.go | 4 - .../api/grpc/feature/v2/converter_test.go | 20 -- .../v2/integration_test/feature_test.go | 14 -- internal/api/grpc/feature/v2beta/converter.go | 4 - .../api/grpc/feature/v2beta/converter_test.go | 20 -- .../v2beta/integration_test/feature_test.go | 13 -- .../middleware/execution_interceptor.go | 62 ++++--- .../middleware/execution_interceptor_test.go | 32 ++-- internal/command/instance_features.go | 2 - internal/command/instance_features_model.go | 5 - internal/command/instance_features_test.go | 23 --- internal/command/system_features.go | 2 - internal/command/system_features_model.go | 5 - internal/command/system_features_test.go | 28 --- internal/execution/execution.go | 6 +- internal/execution/execution_test.go | 43 ++--- internal/feature/feature.go | 3 +- internal/feature/key_enumer.go | 8 +- internal/integration/action.go | 61 +++++++ internal/integration/client.go | 2 +- internal/query/instance_features.go | 1 - internal/query/instance_features_model.go | 7 +- internal/query/instance_features_test.go | 24 --- .../query/projection/instance_features.go | 4 - internal/query/projection/system_features.go | 4 - internal/query/system_features.go | 1 - internal/query/system_features_model.go | 6 +- internal/query/system_features_test.go | 24 --- internal/repository/execution/queue.go | 20 +- .../feature/feature_v2/eventstore.go | 2 - .../repository/feature/feature_v2/feature.go | 2 - proto/buf.yaml | 4 + .../action/v2beta/action_service.proto | 4 +- proto/zitadel/action/v2beta/execution.proto | 14 +- proto/zitadel/action/v2beta/query.proto | 11 -- proto/zitadel/feature/v2/instance.proto | 17 +- proto/zitadel/feature/v2/system.proto | 18 +- proto/zitadel/feature/v2beta/instance.proto | 17 +- proto/zitadel/feature/v2beta/system.proto | 18 +- 55 files changed, 427 insertions(+), 799 deletions(-) diff --git a/docs/docs/guides/integrate/actions/testing-event.md b/docs/docs/guides/integrate/actions/testing-event.md index 69a33f6d3e..8b4502703b 100644 --- a/docs/docs/guides/integrate/actions/testing-event.md +++ b/docs/docs/guides/integrate/actions/testing-event.md @@ -145,7 +145,14 @@ the [Sent information Event](./usage#sent-information-event) payload description "event_type": "user.human.added", "created_at": "2025-03-27T10:22:43.262665+01:00", "userID": "312909075212468632", - "event_payload": "eyJ1c2VyTmFtZSI6ImV4YW1wbGVAdGVzdC5jb20iLCJmaXJzdE5hbWUiOiJUZXN0IiwibGFzdE5hbWUiOiJVc2VyIiwiZGlzcGxheU5hbWUiOiJUZXN0IFVzZXIiLCJwcmVmZXJyZWRMYW5ndWFnZSI6InVuZCIsImVtYWlsIjoiZXhhbXBsZUB0ZXN0LmNvbSJ9" + "event_payload": { + "userName":"example@test.com", + "firstName":"Test", + "lastName":"User", + "displayName":"Test User", + "preferredLanguage":"und", + "email":"example@test.com" + } } ``` diff --git a/docs/docs/guides/integrate/actions/testing-request-manipulation.md b/docs/docs/guides/integrate/actions/testing-request-manipulation.md index f727b56144..1cb4f1776a 100644 --- a/docs/docs/guides/integrate/actions/testing-request-manipulation.md +++ b/docs/docs/guides/integrate/actions/testing-request-manipulation.md @@ -18,6 +18,11 @@ Note that this guide assumes that ZITADEL is running on the same machine as the In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL. ::: +:::warning +To marshal and unmarshal the request please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), +as the request is a protocol buffer message, to avoid potential problems with the attribute names. +::: + ## Start example target To test the actions feature, you need to create a target that will be called when an API endpoint is called. @@ -37,10 +42,28 @@ import ( "net/http" "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "google.golang.org/protobuf/encoding/protojson" ) type contextRequest struct { - Request *user.AddHumanUserRequest `json:"request"` + Request *addHumanUserRequestWrapper `json:"request"` +} + +// addHumanUserRequestWrapper necessary to marshal and unmarshal the JSON into the proto message correctly +type addHumanUserRequestWrapper struct { + user.AddHumanUserRequest +} + +func (r *addHumanUserRequestWrapper) MarshalJSON() ([]byte, error) { + data, err := protojson.Marshal(r) + if err != nil { + return nil, err + } + return data, nil +} + +func (r *addHumanUserRequestWrapper) UnmarshalJSON(data []byte) error { + return protojson.Unmarshal(data, r) } // call HandleFunc to read the request body, manipulate the content and return the manipulated request diff --git a/docs/docs/guides/integrate/actions/testing-request-signature.md b/docs/docs/guides/integrate/actions/testing-request-signature.md index 4565328e63..c1932a7d5b 100644 --- a/docs/docs/guides/integrate/actions/testing-request-signature.md +++ b/docs/docs/guides/integrate/actions/testing-request-signature.md @@ -18,6 +18,11 @@ Note that this guide assumes that ZITADEL is running on the same machine as the In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL. ::: +:::warning +To marshal and unmarshal the request please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), +as the request is a protocol buffer message, to avoid potential problems with the attribute names. +::: + ## Start example target To test the actions feature, you need to create a target that will be called when an API endpoint is called. diff --git a/docs/docs/guides/integrate/actions/testing-request.md b/docs/docs/guides/integrate/actions/testing-request.md index e97b0ef25f..b2413e606e 100644 --- a/docs/docs/guides/integrate/actions/testing-request.md +++ b/docs/docs/guides/integrate/actions/testing-request.md @@ -18,6 +18,11 @@ Note that this guide assumes that ZITADEL is running on the same machine as the In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL. ::: +:::warning +To marshal and unmarshal the request please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), +as the request is a protocol buffer message, to avoid potential problems with the attribute names. +::: + ## Start example target To test the actions feature, you need to create a target that will be called when an API endpoint is called. diff --git a/docs/docs/guides/integrate/actions/testing-response-manipulation.md b/docs/docs/guides/integrate/actions/testing-response-manipulation.md index cc10b8252a..9d95479b05 100644 --- a/docs/docs/guides/integrate/actions/testing-response-manipulation.md +++ b/docs/docs/guides/integrate/actions/testing-response-manipulation.md @@ -18,6 +18,11 @@ Note that this guide assumes that ZITADEL is running on the same machine as the In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL. ::: +:::warning +To marshal and unmarshal the request and response please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), +as the request and response are protocol buffer messages, to avoid potential problems with the attribute names. +::: + ## Start example target To test the actions feature, you need to create a target that will be called when an API endpoint is called. @@ -37,11 +42,46 @@ import ( "net/http" "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "google.golang.org/protobuf/encoding/protojson" ) -type response struct { - Request *user.RetrieveIdentityProviderIntentRequest `json:"request"` - Response *user.RetrieveIdentityProviderIntentResponse `json:"response"` +type contextResponse struct { + Request *retrieveIdentityProviderIntentRequestWrapper `json:"request"` + Response *retrieveIdentityProviderIntentResponseWrapper `json:"response"` +} + +// RetrieveIdentityProviderIntentRequestWrapper necessary to marshal and unmarshal the JSON into the proto message correctly +type retrieveIdentityProviderIntentRequestWrapper struct { + user.RetrieveIdentityProviderIntentRequest +} + +func (r *retrieveIdentityProviderIntentRequestWrapper) MarshalJSON() ([]byte, error) { + data, err := protojson.Marshal(r) + if err != nil { + return nil, err + } + return data, nil +} + +func (r *retrieveIdentityProviderIntentRequestWrapper) UnmarshalJSON(data []byte) error { + return protojson.Unmarshal(data, r) +} + +// RetrieveIdentityProviderIntentResponseWrapper necessary to marshal and unmarshal the JSON into the proto message correctly +type retrieveIdentityProviderIntentResponseWrapper struct { + user.RetrieveIdentityProviderIntentResponse +} + +func (r *retrieveIdentityProviderIntentResponseWrapper) MarshalJSON() ([]byte, error) { + data, err := protojson.Marshal(r) + if err != nil { + return nil, err + } + return data, nil +} + +func (r *retrieveIdentityProviderIntentResponseWrapper) UnmarshalJSON(data []byte) error { + return protojson.Unmarshal(data, r) } // call HandleFunc to read the response body, manipulate the content and return the response @@ -56,7 +96,7 @@ func call(w http.ResponseWriter, req *http.Request) { defer req.Body.Close() // read the response into the expected structure - request := new(response) + request := new(contextResponse) if err := json.Unmarshal(sentBody, request); err != nil { http.Error(w, "error", http.StatusInternalServerError) } diff --git a/docs/docs/guides/integrate/actions/testing-response.md b/docs/docs/guides/integrate/actions/testing-response.md index 3eb824e95b..a2ab736505 100644 --- a/docs/docs/guides/integrate/actions/testing-response.md +++ b/docs/docs/guides/integrate/actions/testing-response.md @@ -18,6 +18,11 @@ Note that this guide assumes that ZITADEL is running on the same machine as the In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL. ::: +:::warning +To marshal and unmarshal the request and response please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), +as the request and response are protocol buffer messages, to avoid potential problems with the attribute names. +::: + ## Start example target To test the actions feature, you need to create a target that will be called when an API endpoint is called. diff --git a/docs/docs/guides/integrate/actions/usage.md b/docs/docs/guides/integrate/actions/usage.md index 8a639f3c27..ba512ae549 100644 --- a/docs/docs/guides/integrate/actions/usage.md +++ b/docs/docs/guides/integrate/actions/usage.md @@ -36,6 +36,11 @@ The information sent to the Endpoint is structured as JSON: } ``` +:::warning +To marshal and unmarshal the request please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), +as the request is a protocol buffer message, to avoid potential problems with the attribute names. +::: + ### Sent information Response The information sent to the Endpoint is structured as JSON: @@ -56,6 +61,11 @@ The information sent to the Endpoint is structured as JSON: } ``` +:::warning +To marshal and unmarshal the request and response please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson), +as the request and response are protocol buffer messages, to avoid potential problems with the attribute names. +::: + ### Sent information Function Information sent and expected back are specific to the function. @@ -338,7 +348,7 @@ The information sent to the Endpoint is structured as JSON: "event_type": "Type of the event", "created_at": "Time the event was created", "userID": "ID of the creator of the event", - "event_payload": "Base64 encoded content of the event" + "event_payload": "Content of the event in JSON format" } ``` diff --git a/internal/api/grpc/action/v2beta/execution.go b/internal/api/grpc/action/v2beta/execution.go index 8a7cd18ab4..5477a8128e 100644 --- a/internal/api/grpc/action/v2beta/execution.go +++ b/internal/api/grpc/action/v2beta/execution.go @@ -14,22 +14,10 @@ import ( ) func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionRequest) (*action.SetExecutionResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } reqTargets := req.GetTargets() targets := make([]*execution.Target, len(reqTargets)) for i, target := range reqTargets { - switch t := target.GetType().(type) { - case *action.ExecutionTargetType_Include: - include, err := conditionToInclude(t.Include) - if err != nil { - return nil, err - } - targets[i] = &execution.Target{Type: domain.ExecutionTargetTypeInclude, Target: include} - case *action.ExecutionTargetType_Target: - targets[i] = &execution.Target{Type: domain.ExecutionTargetTypeTarget, Target: t.Target} - } + targets[i] = &execution.Target{Type: domain.ExecutionTargetTypeTarget, Target: target} } set := &command.SetExecution{ Targets: targets, @@ -60,59 +48,19 @@ func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionReque }, nil } -func conditionToInclude(cond *action.Condition) (string, error) { - switch t := cond.GetConditionType().(type) { - case *action.Condition_Request: - cond := executionConditionFromRequest(t.Request) - if err := cond.IsValid(); err != nil { - return "", err - } - return cond.ID(domain.ExecutionTypeRequest), nil - case *action.Condition_Response: - cond := executionConditionFromResponse(t.Response) - if err := cond.IsValid(); err != nil { - return "", err - } - return cond.ID(domain.ExecutionTypeRequest), nil - case *action.Condition_Event: - cond := executionConditionFromEvent(t.Event) - if err := cond.IsValid(); err != nil { - return "", err - } - return cond.ID(), nil - case *action.Condition_Function: - cond := command.ExecutionFunctionCondition(t.Function.GetName()) - if err := cond.IsValid(); err != nil { - return "", err - } - return cond.ID(), nil - default: - return "", zerrors.ThrowInvalidArgument(nil, "ACTION-9BBob", "Errors.Execution.ConditionInvalid") - } -} - func (s *Server) ListExecutionFunctions(ctx context.Context, _ *action.ListExecutionFunctionsRequest) (*action.ListExecutionFunctionsResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } return &action.ListExecutionFunctionsResponse{ Functions: s.ListActionFunctions(), }, nil } func (s *Server) ListExecutionMethods(ctx context.Context, _ *action.ListExecutionMethodsRequest) (*action.ListExecutionMethodsResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } return &action.ListExecutionMethodsResponse{ Methods: s.ListGRPCMethods(), }, nil } func (s *Server) ListExecutionServices(ctx context.Context, _ *action.ListExecutionServicesRequest) (*action.ListExecutionServicesResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } return &action.ListExecutionServicesResponse{ Services: s.ListGRPCServices(), }, nil diff --git a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go index 6e3ab76fac..0c5018dbb6 100644 --- a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go @@ -48,7 +48,6 @@ var ( func TestServer_ExecutionTarget(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) fullMethod := action.ActionService_GetTarget_FullMethodName @@ -77,14 +76,14 @@ func TestServer_ExecutionTarget(t *testing.T) { targetCreated := instance.CreateTarget(ctx, t, targetCreatedName, targetCreatedURL, domain.TargetTypeCall, false) // request received by target - wantRequest := &middleware.ContextInfoRequest{FullMethod: fullMethod, InstanceID: instance.ID(), OrgID: orgID, ProjectID: projectID, UserID: userID, Request: request} + wantRequest := &middleware.ContextInfoRequest{FullMethod: fullMethod, InstanceID: instance.ID(), OrgID: orgID, ProjectID: projectID, UserID: userID, Request: middleware.Message{Message: request}} changedRequest := &action.GetTargetRequest{Id: targetCreated.GetId()} // replace original request with different targetID - urlRequest, closeRequest, calledRequest, _ := integration.TestServerCall(wantRequest, 0, http.StatusOK, changedRequest) + urlRequest, closeRequest, calledRequest, _ := integration.TestServerCallProto(wantRequest, 0, http.StatusOK, changedRequest) targetRequest := waitForTarget(ctx, t, instance, urlRequest, domain.TargetTypeCall, false) - waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), executionTargetsSingleTarget(targetRequest.GetId())) + waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), []string{targetRequest.GetId()}) // expected response from the GetTarget expectedResponse := &action.GetTargetResponse{ @@ -103,9 +102,26 @@ func TestServer_ExecutionTarget(t *testing.T) { SigningKey: targetCreated.GetSigningKey(), }, } - // has to be set separately because of the pointers + + changedResponse := &action.GetTargetResponse{ + Target: &action.Target{ + Id: "changed", + CreationDate: targetCreated.GetCreationDate(), + ChangeDate: targetCreated.GetCreationDate(), + Name: targetCreatedName, + TargetType: &action.Target_RestCall{ + RestCall: &action.RESTCall{ + InterruptOnError: false, + }, + }, + Timeout: durationpb.New(5 * time.Second), + Endpoint: targetCreatedURL, + SigningKey: targetCreated.GetSigningKey(), + }, + } + // content for update response.Target = &action.Target{ - Id: targetCreated.GetId(), + Id: "changed", CreationDate: targetCreated.GetCreationDate(), ChangeDate: targetCreated.GetCreationDate(), Name: targetCreatedName, @@ -119,18 +135,6 @@ func TestServer_ExecutionTarget(t *testing.T) { SigningKey: targetCreated.GetSigningKey(), } - // content for partial update - changedResponse := &action.GetTargetResponse{ - Target: &action.Target{ - Id: targetCreated.GetId(), - TargetType: &action.Target_RestCall{ - RestCall: &action.RESTCall{ - InterruptOnError: false, - }, - }, - }, - } - // response received by target wantResponse := &middleware.ContextInfoResponse{ FullMethod: fullMethod, @@ -138,14 +142,14 @@ func TestServer_ExecutionTarget(t *testing.T) { OrgID: orgID, ProjectID: projectID, UserID: userID, - Request: changedRequest, - Response: expectedResponse, + Request: middleware.Message{Message: changedRequest}, + Response: middleware.Message{Message: expectedResponse}, } // after request with different targetID, return changed response - targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCall(wantResponse, 0, http.StatusOK, changedResponse) + targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCallProto(wantResponse, 0, http.StatusOK, changedResponse) targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, domain.TargetTypeCall, false) - waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), executionTargetsSingleTarget(targetResponse.GetId())) + waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), []string{targetResponse.GetId()}) return func() { closeRequest() closeResponse() @@ -167,9 +171,7 @@ func TestServer_ExecutionTarget(t *testing.T) { Id: "something", }, want: &action.GetTargetResponse{ - Target: &action.Target{ - Id: "changed", - }, + // defined in the dependency function }, }, { @@ -181,11 +183,11 @@ func TestServer_ExecutionTarget(t *testing.T) { userID := instance.Users.Get(integration.UserTypeIAMOwner).ID // request received by target - wantRequest := &middleware.ContextInfoRequest{FullMethod: fullMethod, InstanceID: instance.ID(), OrgID: orgID, ProjectID: projectID, UserID: userID, Request: request} - urlRequest, closeRequest, calledRequest, _ := integration.TestServerCall(wantRequest, 0, http.StatusInternalServerError, &action.GetTargetRequest{Id: "notchanged"}) + wantRequest := &middleware.ContextInfoRequest{FullMethod: fullMethod, InstanceID: instance.ID(), OrgID: orgID, ProjectID: projectID, UserID: userID, Request: middleware.Message{Message: request}} + urlRequest, closeRequest, calledRequest, _ := integration.TestServerCallProto(wantRequest, 0, http.StatusInternalServerError, nil) targetRequest := waitForTarget(ctx, t, instance, urlRequest, domain.TargetTypeCall, true) - waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), executionTargetsSingleTarget(targetRequest.GetId())) + waitForExecutionOnCondition(ctx, t, instance, conditionRequestFullMethod(fullMethod), []string{targetRequest.GetId()}) // GetTarget with used target request.Id = targetRequest.GetId() return func() { @@ -234,17 +236,6 @@ func TestServer_ExecutionTarget(t *testing.T) { SigningKey: targetCreated.GetSigningKey(), }, } - // content for partial update - changedResponse := &action.GetTargetResponse{ - Target: &action.Target{ - Id: "changed", - TargetType: &action.Target_RestCall{ - RestCall: &action.RESTCall{ - InterruptOnError: false, - }, - }, - }, - } // response received by target wantResponse := &middleware.ContextInfoResponse{ @@ -253,14 +244,14 @@ func TestServer_ExecutionTarget(t *testing.T) { OrgID: orgID, ProjectID: projectID, UserID: userID, - Request: request, - Response: expectedResponse, + Request: middleware.Message{Message: request}, + Response: middleware.Message{Message: expectedResponse}, } // after request with different targetID, return changed response - targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCall(wantResponse, 0, http.StatusInternalServerError, changedResponse) + targetResponseURL, closeResponse, calledResponse, _ := integration.TestServerCallProto(wantResponse, 0, http.StatusInternalServerError, nil) targetResponse := waitForTarget(ctx, t, instance, targetResponseURL, domain.TargetTypeCall, true) - waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), executionTargetsSingleTarget(targetResponse.GetId())) + waitForExecutionOnCondition(ctx, t, instance, conditionResponseFullMethod(fullMethod), []string{targetResponse.GetId()}) return func() { closeResponse() }, func() bool { @@ -301,7 +292,6 @@ func TestServer_ExecutionTarget(t *testing.T) { func TestServer_ExecutionTarget_Event(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) event := "session.added" @@ -309,7 +299,7 @@ func TestServer_ExecutionTarget_Event(t *testing.T) { defer closeF() targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, domain.TargetTypeWebhook, true) - waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), executionTargetsSingleTarget(targetResponse.GetId())) + waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) tests := []struct { name string @@ -359,7 +349,6 @@ func TestServer_ExecutionTarget_Event(t *testing.T) { func TestServer_ExecutionTarget_Event_LongerThanTargetTimeout(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) event := "session.added" @@ -368,7 +357,7 @@ func TestServer_ExecutionTarget_Event_LongerThanTargetTimeout(t *testing.T) { defer closeF() targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, domain.TargetTypeWebhook, true) - waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), executionTargetsSingleTarget(targetResponse.GetId())) + waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) tests := []struct { name string @@ -412,7 +401,6 @@ func TestServer_ExecutionTarget_Event_LongerThanTargetTimeout(t *testing.T) { func TestServer_ExecutionTarget_Event_LongerThanTransactionTimeout(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) event := "session.added" @@ -420,7 +408,7 @@ func TestServer_ExecutionTarget_Event_LongerThanTransactionTimeout(t *testing.T) defer closeF() targetResponse := waitForTarget(isolatedIAMOwnerCTX, t, instance, urlRequest, domain.TargetTypeWebhook, true) - waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), executionTargetsSingleTarget(targetResponse.GetId())) + waitForExecutionOnCondition(isolatedIAMOwnerCTX, t, instance, conditionEvent(event), []string{targetResponse.GetId()}) tests := []struct { name string @@ -474,7 +462,7 @@ func TestServer_ExecutionTarget_Event_LongerThanTransactionTimeout(t *testing.T) } } -func waitForExecutionOnCondition(ctx context.Context, t *testing.T, instance *integration.Instance, condition *action.Condition, targets []*action.ExecutionTargetType) { +func waitForExecutionOnCondition(ctx context.Context, t *testing.T, instance *integration.Instance, condition *action.Condition, targets []string) { instance.SetExecution(ctx, t, condition, targets) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) @@ -496,7 +484,7 @@ func waitForExecutionOnCondition(ctx context.Context, t *testing.T, instance *in // always first check length, otherwise its failed anyway if assert.Len(ttt, gotTargets, len(targets)) { for i := range targets { - assert.EqualExportedValues(ttt, targets[i].GetType(), gotTargets[i].GetType()) + assert.EqualExportedValues(ttt, targets[i], gotTargets[i]) } } }, retryDuration, tick, "timeout waiting for expected execution result") @@ -589,7 +577,6 @@ func conditionFunction(function string) *action.Condition { func TestServer_ExecutionTargetPreUserinfo(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) @@ -785,7 +772,7 @@ func expectPreUserinfoExecution(ctx context.Context, t *testing.T, instance *int targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) targetResp := waitForTarget(ctx, t, instance, targetURL, domain.TargetTypeCall, true) - waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preuserinfo"), executionTargetsSingleTarget(targetResp.GetId())) + waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preuserinfo"), []string{targetResp.GetId()}) return userResp.GetUserId(), closeF } @@ -903,7 +890,6 @@ func contextInfoForUserOIDC(instance *integration.Instance, function string, use func TestServer_ExecutionTargetPreAccessToken(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) @@ -1091,13 +1077,12 @@ func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance * targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) targetResp := waitForTarget(ctx, t, instance, targetURL, domain.TargetTypeCall, true) - waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preaccesstoken"), executionTargetsSingleTarget(targetResp.GetId())) + waitForExecutionOnCondition(ctx, t, instance, conditionFunction("preaccesstoken"), []string{targetResp.GetId()}) return userResp.GetUserId(), closeF } func TestServer_ExecutionTargetPreSAMLResponse(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) @@ -1257,7 +1242,7 @@ func expectPreSAMLResponseExecution(ctx context.Context, t *testing.T, instance targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response) targetResp := waitForTarget(ctx, t, instance, targetURL, domain.TargetTypeCall, true) - waitForExecutionOnCondition(ctx, t, instance, conditionFunction("presamlresponse"), executionTargetsSingleTarget(targetResp.GetId())) + waitForExecutionOnCondition(ctx, t, instance, conditionFunction("presamlresponse"), []string{targetResp.GetId()}) return userResp.GetUserId(), closeF } diff --git a/internal/api/grpc/action/v2beta/integration_test/execution_test.go b/internal/api/grpc/action/v2beta/integration_test/execution_test.go index 3af419d97b..2199b9f454 100644 --- a/internal/api/grpc/action/v2beta/integration_test/execution_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/execution_test.go @@ -15,17 +15,8 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) -func executionTargetsSingleTarget(id string) []*action.ExecutionTargetType { - return []*action.ExecutionTargetType{{Type: &action.ExecutionTargetType_Target{Target: id}}} -} - -func executionTargetsSingleInclude(include *action.Condition) []*action.ExecutionTargetType { - return []*action.ExecutionTargetType{{Type: &action.ExecutionTargetType_Include{Include: include}}} -} - func TestServer_SetExecution_Request(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) @@ -59,7 +50,7 @@ func TestServer_SetExecution_Request(t *testing.T) { Request: &action.RequestExecution{}, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -76,7 +67,7 @@ func TestServer_SetExecution_Request(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -93,7 +84,7 @@ func TestServer_SetExecution_Request(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -110,7 +101,7 @@ func TestServer_SetExecution_Request(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -127,7 +118,7 @@ func TestServer_SetExecution_Request(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -144,7 +135,7 @@ func TestServer_SetExecution_Request(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -181,125 +172,8 @@ func assertSetExecutionResponse(t *testing.T, creationDate, setDate time.Time, e } } -func TestServer_SetExecution_Request_Include(t *testing.T) { - instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) - executionCond := &action.Condition{ - ConditionType: &action.Condition_Request{ - Request: &action.RequestExecution{ - Condition: &action.RequestExecution_All{ - All: true, - }, - }, - }, - } - instance.SetExecution(isolatedIAMOwnerCTX, t, - executionCond, - executionTargetsSingleTarget(targetResp.GetId()), - ) - - circularExecutionService := &action.Condition{ - ConditionType: &action.Condition_Request{ - Request: &action.RequestExecution{ - Condition: &action.RequestExecution_Service{ - Service: "zitadel.session.v2beta.SessionService", - }, - }, - }, - } - instance.SetExecution(isolatedIAMOwnerCTX, t, - circularExecutionService, - executionTargetsSingleInclude(executionCond), - ) - circularExecutionMethod := &action.Condition{ - ConditionType: &action.Condition_Request{ - Request: &action.RequestExecution{ - Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/ListSessions", - }, - }, - }, - } - instance.SetExecution(isolatedIAMOwnerCTX, t, - circularExecutionMethod, - executionTargetsSingleInclude(circularExecutionService), - ) - - tests := []struct { - name string - ctx context.Context - req *action.SetExecutionRequest - wantSetDate bool - wantErr bool - }{ - { - name: "method, circular error", - ctx: isolatedIAMOwnerCTX, - req: &action.SetExecutionRequest{ - Condition: circularExecutionService, - Targets: executionTargetsSingleInclude(circularExecutionMethod), - }, - wantErr: true, - }, - { - name: "method, ok", - ctx: isolatedIAMOwnerCTX, - req: &action.SetExecutionRequest{ - Condition: &action.Condition{ - ConditionType: &action.Condition_Request{ - Request: &action.RequestExecution{ - Condition: &action.RequestExecution_Method{ - Method: "/zitadel.session.v2beta.SessionService/ListSessions", - }, - }, - }, - }, - Targets: executionTargetsSingleInclude(executionCond), - }, - wantSetDate: true, - }, - { - name: "service, ok", - ctx: isolatedIAMOwnerCTX, - req: &action.SetExecutionRequest{ - Condition: &action.Condition{ - ConditionType: &action.Condition_Request{ - Request: &action.RequestExecution{ - Condition: &action.RequestExecution_Service{ - Service: "zitadel.user.v2beta.UserService", - }, - }, - }, - }, - Targets: executionTargetsSingleInclude(executionCond), - }, - wantSetDate: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - creationDate := time.Now().UTC() - got, err := instance.Client.ActionV2beta.SetExecution(tt.ctx, tt.req) - setDate := time.Now().UTC() - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - - assertSetExecutionResponse(t, creationDate, setDate, tt.wantSetDate, got) - - // cleanup to not impact other requests - instance.DeleteExecution(tt.ctx, t, tt.req.GetCondition()) - }) - } -} - func TestServer_SetExecution_Response(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) @@ -333,7 +207,7 @@ func TestServer_SetExecution_Response(t *testing.T) { Response: &action.ResponseExecution{}, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -350,7 +224,7 @@ func TestServer_SetExecution_Response(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -367,7 +241,7 @@ func TestServer_SetExecution_Response(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -384,7 +258,7 @@ func TestServer_SetExecution_Response(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -401,7 +275,7 @@ func TestServer_SetExecution_Response(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -418,7 +292,7 @@ func TestServer_SetExecution_Response(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -444,7 +318,6 @@ func TestServer_SetExecution_Response(t *testing.T) { func TestServer_SetExecution_Event(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) @@ -480,7 +353,7 @@ func TestServer_SetExecution_Event(t *testing.T) { Event: &action.EventExecution{}, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -497,7 +370,7 @@ func TestServer_SetExecution_Event(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -514,7 +387,7 @@ func TestServer_SetExecution_Event(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -531,7 +404,7 @@ func TestServer_SetExecution_Event(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -548,7 +421,7 @@ func TestServer_SetExecution_Event(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -565,7 +438,7 @@ func TestServer_SetExecution_Event(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -582,7 +455,7 @@ func TestServer_SetExecution_Event(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, @@ -608,7 +481,6 @@ func TestServer_SetExecution_Event(t *testing.T) { func TestServer_SetExecution_Function(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://notexisting", domain.TargetTypeWebhook, false) @@ -642,7 +514,7 @@ func TestServer_SetExecution_Function(t *testing.T) { Response: &action.ResponseExecution{}, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -655,7 +527,7 @@ func TestServer_SetExecution_Function(t *testing.T) { Function: &action.FunctionExecution{Name: "xxx"}, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantErr: true, }, @@ -668,7 +540,7 @@ func TestServer_SetExecution_Function(t *testing.T) { Function: &action.FunctionExecution{Name: "presamlresponse"}, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, wantSetDate: true, }, diff --git a/internal/api/grpc/action/v2beta/integration_test/query_test.go b/internal/api/grpc/action/v2beta/integration_test/query_test.go index c5159d39da..5c59bee5d1 100644 --- a/internal/api/grpc/action/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/query_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/durationpb" @@ -20,7 +21,6 @@ import ( func TestServer_GetTarget(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context @@ -213,7 +213,6 @@ func TestServer_GetTarget(t *testing.T) { func TestServer_ListTargets(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context @@ -446,7 +445,6 @@ func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationRes func TestServer_ListExecutions(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) targetResp := instance.CreateTarget(isolatedIAMOwnerCTX, t, "", "https://example.com", domain.TargetTypeWebhook, false) @@ -475,7 +473,7 @@ func TestServer_ListExecutions(t *testing.T) { ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { cond := request.Filters[0].GetInConditionsFilter().GetConditions()[0] - resp := instance.SetExecution(ctx, t, cond, executionTargetsSingleTarget(targetResp.GetId())) + resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) // Set expected response with used values for SetExecution response.Result[0].CreationDate = resp.GetSetDate() @@ -516,7 +514,7 @@ func TestServer_ListExecutions(t *testing.T) { }, }, }, - Targets: executionTargetsSingleTarget(targetResp.GetId()), + Targets: []string{targetResp.GetId()}, }, }, }, @@ -544,13 +542,12 @@ func TestServer_ListExecutions(t *testing.T) { }, }, } - targets := executionTargetsSingleTarget(target.GetId()) - resp := instance.SetExecution(ctx, t, cond, targets) + resp := instance.SetExecution(ctx, t, cond, []string{target.GetId()}) response.Result[0].CreationDate = resp.GetSetDate() response.Result[0].ChangeDate = resp.GetSetDate() response.Result[0].Condition = cond - response.Result[0].Targets = targets + response.Result[0].Targets = []string{target.GetId()} }, req: &action.ListExecutionsRequest{ Filters: []*action.ExecutionSearchFilter{{}}, @@ -564,63 +561,10 @@ func TestServer_ListExecutions(t *testing.T) { Result: []*action.Execution{ { Condition: &action.Condition{}, - Targets: executionTargetsSingleTarget(""), + Targets: []string{""}, }, }, }, - }, { - name: "list request single include", - args: args{ - ctx: isolatedIAMOwnerCTX, - dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { - cond := &action.Condition{ - ConditionType: &action.Condition_Request{ - Request: &action.RequestExecution{ - Condition: &action.RequestExecution_Method{ - Method: "/zitadel.management.v1.ManagementService/GetAction", - }, - }, - }, - } - instance.SetExecution(ctx, t, cond, executionTargetsSingleTarget(targetResp.GetId())) - request.Filters[0].GetIncludeFilter().Include = cond - - includeCond := &action.Condition{ - ConditionType: &action.Condition_Request{ - Request: &action.RequestExecution{ - Condition: &action.RequestExecution_Method{ - Method: "/zitadel.management.v1.ManagementService/ListActions", - }, - }, - }, - } - includeTargets := executionTargetsSingleInclude(cond) - resp2 := instance.SetExecution(ctx, t, includeCond, includeTargets) - - response.Result[0] = &action.Execution{ - Condition: includeCond, - CreationDate: resp2.GetSetDate(), - ChangeDate: resp2.GetSetDate(), - Targets: includeTargets, - } - }, - req: &action.ListExecutionsRequest{ - Filters: []*action.ExecutionSearchFilter{{ - Filter: &action.ExecutionSearchFilter_IncludeFilter{ - IncludeFilter: &action.IncludeFilter{}, - }, - }}, - }, - }, - want: &action.ListExecutionsResponse{ - Pagination: &filter.PaginationResponse{ - TotalResult: 1, - AppliedLimit: 100, - }, - Result: []*action.Execution{ - {}, - }, - }, }, { name: "list multiple conditions", @@ -659,33 +603,30 @@ func TestServer_ListExecutions(t *testing.T) { } cond1 := request.Filters[0].GetInConditionsFilter().GetConditions()[0] - targets1 := executionTargetsSingleTarget(targetResp.GetId()) - resp1 := instance.SetExecution(ctx, t, cond1, targets1) + resp1 := instance.SetExecution(ctx, t, cond1, []string{targetResp.GetId()}) response.Result[2] = &action.Execution{ CreationDate: resp1.GetSetDate(), ChangeDate: resp1.GetSetDate(), Condition: cond1, - Targets: targets1, + Targets: []string{targetResp.GetId()}, } cond2 := request.Filters[0].GetInConditionsFilter().GetConditions()[1] - targets2 := executionTargetsSingleTarget(targetResp.GetId()) - resp2 := instance.SetExecution(ctx, t, cond2, targets2) + resp2 := instance.SetExecution(ctx, t, cond2, []string{targetResp.GetId()}) response.Result[1] = &action.Execution{ CreationDate: resp2.GetSetDate(), ChangeDate: resp2.GetSetDate(), Condition: cond2, - Targets: targets2, + Targets: []string{targetResp.GetId()}, } cond3 := request.Filters[0].GetInConditionsFilter().GetConditions()[2] - targets3 := executionTargetsSingleTarget(targetResp.GetId()) - resp3 := instance.SetExecution(ctx, t, cond3, targets3) + resp3 := instance.SetExecution(ctx, t, cond3, []string{targetResp.GetId()}) response.Result[0] = &action.Execution{ CreationDate: resp3.GetSetDate(), ChangeDate: resp3.GetSetDate(), Condition: cond3, - Targets: targets3, + Targets: []string{targetResp.GetId()}, } }, req: &action.ListExecutionsRequest{ @@ -709,15 +650,14 @@ func TestServer_ListExecutions(t *testing.T) { args: args{ ctx: isolatedIAMOwnerCTX, dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { - targets := executionTargetsSingleTarget(targetResp.GetId()) conditions := request.Filters[0].GetInConditionsFilter().GetConditions() for i, cond := range conditions { - resp := instance.SetExecution(ctx, t, cond, targets) + resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) response.Result[(len(conditions)-1)-i] = &action.Execution{ CreationDate: resp.GetSetDate(), ChangeDate: resp.GetSetDate(), Condition: cond, - Targets: targets, + Targets: []string{targetResp.GetId()}, } } }, @@ -761,6 +701,63 @@ func TestServer_ListExecutions(t *testing.T) { }, }, }, + { + name: "list multiple conditions all types, sort id", + args: args{ + ctx: isolatedIAMOwnerCTX, + dep: func(ctx context.Context, request *action.ListExecutionsRequest, response *action.ListExecutionsResponse) { + conditions := request.Filters[0].GetInConditionsFilter().GetConditions() + for i, cond := range conditions { + resp := instance.SetExecution(ctx, t, cond, []string{targetResp.GetId()}) + response.Result[i] = &action.Execution{ + CreationDate: resp.GetSetDate(), + ChangeDate: resp.GetSetDate(), + Condition: cond, + Targets: []string{targetResp.GetId()}, + } + } + }, + req: &action.ListExecutionsRequest{ + SortingColumn: gu.Ptr(action.ExecutionFieldName_EXECUTION_FIELD_NAME_ID), + Filters: []*action.ExecutionSearchFilter{{ + Filter: &action.ExecutionSearchFilter_InConditionsFilter{ + InConditionsFilter: &action.InConditionsFilter{ + Conditions: []*action.Condition{ + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, + {ConditionType: &action.Condition_Response{Response: &action.ResponseExecution{Condition: &action.ResponseExecution_All{All: true}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Method{Method: "/zitadel.session.v2.SessionService/GetSession"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_Service{Service: "zitadel.session.v2.SessionService"}}}}, + {ConditionType: &action.Condition_Request{Request: &action.RequestExecution{Condition: &action.RequestExecution_All{All: true}}}}, + {ConditionType: &action.Condition_Function{Function: &action.FunctionExecution{Name: "presamlresponse"}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Event{Event: "user.added"}}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_Group{Group: "user"}}}}, + {ConditionType: &action.Condition_Event{Event: &action.EventExecution{Condition: &action.EventExecution_All{All: true}}}}, + }, + }, + }, + }}, + }, + }, + want: &action.ListExecutionsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 10, + AppliedLimit: 100, + }, + Result: []*action.Execution{ + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + {}, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/grpc/action/v2beta/integration_test/server_test.go b/internal/api/grpc/action/v2beta/integration_test/server_test.go index 89a33dd40e..07ee051c63 100644 --- a/internal/api/grpc/action/v2beta/integration_test/server_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/server_test.go @@ -7,14 +7,6 @@ import ( "os" "testing" "time" - - "github.com/muhlemmer/gu" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/zitadel/zitadel/internal/integration" - action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) var ( @@ -29,41 +21,3 @@ func TestMain(m *testing.M) { return m.Run() }()) } - -func ensureFeatureEnabled(t *testing.T, instance *integration.Instance) { - ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ - Inheritance: true, - }) - require.NoError(t, err) - if f.Actions.GetEnabled() { - return - } - _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ - Actions: gu.Ptr(true), - }) - require.NoError(t, err) - - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) - require.EventuallyWithT(t, - func(ttt *assert.CollectT) { - f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ - Inheritance: true, - }) - assert.NoError(ttt, err) - assert.True(ttt, f.Actions.GetEnabled()) - }, - retryDuration, - tick, - "timed out waiting for ensuring instance feature") - - retryDuration, tick = integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) - require.EventuallyWithT(t, - func(ttt *assert.CollectT) { - _, err := instance.Client.ActionV2beta.ListExecutionMethods(ctx, &action.ListExecutionMethodsRequest{}) - assert.NoError(ttt, err) - }, - retryDuration, - tick, - "timed out waiting for ensuring instance feature call") -} diff --git a/internal/api/grpc/action/v2beta/integration_test/target_test.go b/internal/api/grpc/action/v2beta/integration_test/target_test.go index 2fda64b86a..8238d3146d 100644 --- a/internal/api/grpc/action/v2beta/integration_test/target_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/target_test.go @@ -19,7 +19,6 @@ import ( func TestServer_CreateTarget(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type want struct { id bool @@ -244,7 +243,6 @@ func assertCreateTargetResponse(t *testing.T, creationDate, changeDate time.Time func TestServer_UpdateTarget(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) type args struct { ctx context.Context @@ -463,7 +461,6 @@ func assertUpdateTargetResponse(t *testing.T, creationDate, changeDate time.Time func TestServer_DeleteTarget(t *testing.T) { instance := integration.NewInstance(CTX) - ensureFeatureEnabled(t, instance) iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) tests := []struct { name string diff --git a/internal/api/grpc/action/v2beta/query.go b/internal/api/grpc/action/v2beta/query.go index d8d6cd3e95..66bafa4e7d 100644 --- a/internal/api/grpc/action/v2beta/query.go +++ b/internal/api/grpc/action/v2beta/query.go @@ -23,10 +23,6 @@ const ( ) func (s *Server) GetTarget(ctx context.Context, req *action.GetTargetRequest) (*action.GetTargetResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } - resp, err := s.query.GetTargetByID(ctx, req.GetId()) if err != nil { return nil, err @@ -46,9 +42,6 @@ type Context interface { } func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest) (*action.ListTargetsResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } queries, err := s.ListTargetsRequestToModel(req) if err != nil { return nil, err @@ -64,9 +57,6 @@ func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest } func (s *Server) ListExecutions(ctx context.Context, req *action.ListExecutionsRequest) (*action.ListExecutionsResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } queries, err := s.ListExecutionsRequestToModel(req) if err != nil { return nil, err @@ -252,12 +242,6 @@ func executionQueryToQuery(searchQuery *action.ExecutionSearchFilter) (query.Sea return inConditionsQueryToQuery(q.InConditionsFilter) case *action.ExecutionSearchFilter_ExecutionTypeFilter: return executionTypeToQuery(q.ExecutionTypeFilter) - case *action.ExecutionSearchFilter_IncludeFilter: - include, err := conditionToInclude(q.IncludeFilter.GetInclude()) - if err != nil { - return nil, err - } - return query.NewIncludeSearchQuery(include) case *action.ExecutionSearchFilter_TargetFilter: return query.NewTargetSearchQuery(q.TargetFilter.GetTargetId()) default: @@ -333,14 +317,12 @@ func executionsToPb(executions []*query.Execution) []*action.Execution { } func executionToPb(e *query.Execution) *action.Execution { - targets := make([]*action.ExecutionTargetType, len(e.Targets)) + targets := make([]string, len(e.Targets)) for i := range e.Targets { switch e.Targets[i].Type { - case domain.ExecutionTargetTypeInclude: - targets[i] = &action.ExecutionTargetType{Type: &action.ExecutionTargetType_Include{Include: executionIDToCondition(e.Targets[i].Target)}} case domain.ExecutionTargetTypeTarget: - targets[i] = &action.ExecutionTargetType{Type: &action.ExecutionTargetType_Target{Target: e.Targets[i].Target}} - case domain.ExecutionTargetTypeUnspecified: + targets[i] = e.Targets[i].Target + case domain.ExecutionTargetTypeInclude, domain.ExecutionTargetTypeUnspecified: continue default: continue diff --git a/internal/api/grpc/action/v2beta/server.go b/internal/api/grpc/action/v2beta/server.go index 069f456ceb..ef0d8eb2ba 100644 --- a/internal/api/grpc/action/v2beta/server.go +++ b/internal/api/grpc/action/v2beta/server.go @@ -1,8 +1,6 @@ package action import ( - "context" - "google.golang.org/grpc" "github.com/zitadel/zitadel/internal/api/authz" @@ -10,7 +8,6 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/zerrors" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) @@ -65,10 +62,3 @@ func (s *Server) AuthMethods() authz.MethodMapping { func (s *Server) RegisterGateway() server.RegisterGatewayFunc { return action.RegisterActionServiceHandler } - -func checkActionsEnabled(ctx context.Context) error { - if authz.GetInstance(ctx).Features().Actions { - return nil - } - return zerrors.ThrowPreconditionFailed(nil, "ACTION-8o6pvqfjhs", "Errors.Action.NotEnabled") -} diff --git a/internal/api/grpc/action/v2beta/target.go b/internal/api/grpc/action/v2beta/target.go index 7dc636f29a..26c88b9683 100644 --- a/internal/api/grpc/action/v2beta/target.go +++ b/internal/api/grpc/action/v2beta/target.go @@ -14,9 +14,6 @@ import ( ) func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetRequest) (*action.CreateTargetResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } add := createTargetToCommand(req) instanceID := authz.GetInstance(ctx).InstanceID() createdAt, err := s.command.AddTarget(ctx, add, instanceID) @@ -35,9 +32,6 @@ func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetReque } func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetRequest) (*action.UpdateTargetResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } instanceID := authz.GetInstance(ctx).InstanceID() update := updateTargetToCommand(req) changedAt, err := s.command.ChangeTarget(ctx, update, instanceID) @@ -55,9 +49,6 @@ func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetReque } func (s *Server) DeleteTarget(ctx context.Context, req *action.DeleteTargetRequest) (*action.DeleteTargetResponse, error) { - if err := checkActionsEnabled(ctx); err != nil { - return nil, err - } instanceID := authz.GetInstance(ctx).InstanceID() deletedAt, err := s.command.DeleteTarget(ctx, req.GetId(), instanceID) if err != nil { diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 60cf569082..baa45c6c6e 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -22,7 +22,6 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, - Actions: req.Actions, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, @@ -41,7 +40,6 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - Actions: featureSourceToFlagPb(&f.Actions), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), @@ -62,7 +60,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, - Actions: req.Actions, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), WebKey: req.WebKey, DebugOIDCParentError: req.DebugOidcParentError, @@ -83,7 +80,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - Actions: featureSourceToFlagPb(&f.Actions), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), WebKey: featureSourceToFlagPb(&f.WebKey), DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index 62cf701eec..f481e4f65a 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -23,7 +23,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { OidcTriggerIntrospectionProjections: gu.Ptr(false), OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, OidcSingleV1SessionTermination: gu.Ptr(true), @@ -37,7 +36,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: nil, UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, OIDCSingleV1SessionTermination: gu.Ptr(true), @@ -74,10 +72,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - Actions: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, TokenExchange: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -132,10 +126,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: false, Source: feature_pb.Source_SOURCE_SYSTEM, }, - Actions: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_SYSTEM, - }, ImprovedPerformance: &feature_pb.ImprovedPerformanceFeatureFlag{ ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -173,7 +163,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), - Actions: gu.Ptr(true), ImprovedPerformance: nil, WebKey: gu.Ptr(true), DebugOidcParentError: gu.Ptr(true), @@ -191,7 +180,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), - Actions: gu.Ptr(true), ImprovedPerformance: nil, WebKey: gu.Ptr(true), DebugOIDCParentError: gu.Ptr(true), @@ -231,10 +219,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - Actions: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, TokenExchange: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -293,10 +277,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, }, - Actions: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, OidcTokenExchange: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_SYSTEM, diff --git a/internal/api/grpc/feature/v2/integration_test/feature_test.go b/internal/api/grpc/feature/v2/integration_test/feature_test.go index 8d6c295350..f27b57ff8c 100644 --- a/internal/api/grpc/feature/v2/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2/integration_test/feature_test.go @@ -211,7 +211,6 @@ func TestServer_GetSystemFeatures(t *testing.T) { assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) - assertFeatureFlag(t, tt.want.Actions, got.Actions) }) } } @@ -374,10 +373,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - Actions: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, }, }, { @@ -387,7 +382,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), }) require.NoError(t, err) }, @@ -408,10 +402,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - Actions: &feature.FeatureFlag{ - Enabled: true, - Source: feature.Source_SOURCE_INSTANCE, - }, }, }, { @@ -445,10 +435,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - Actions: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, }, }, } diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 39f2284beb..bbb375716e 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -14,7 +14,6 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command. TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, - Actions: req.Actions, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, @@ -29,7 +28,6 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - Actions: featureSourceToFlagPb(&f.Actions), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } @@ -42,7 +40,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, - Actions: req.Actions, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), WebKey: req.WebKey, DebugOIDCParentError: req.DebugOidcParentError, @@ -58,7 +55,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - Actions: featureSourceToFlagPb(&f.Actions), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), WebKey: featureSourceToFlagPb(&f.WebKey), DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 2896d8f77b..72d91b10d4 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -22,7 +22,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { OidcTriggerIntrospectionProjections: gu.Ptr(false), OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, OidcSingleV1SessionTermination: gu.Ptr(true), @@ -32,7 +31,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: nil, UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, OIDCSingleV1SessionTermination: gu.Ptr(true), @@ -64,10 +62,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - Actions: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, TokenExchange: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -107,10 +101,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: false, Source: feature_pb.Source_SOURCE_SYSTEM, }, - Actions: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_SYSTEM, - }, ImprovedPerformance: &feature_pb.ImprovedPerformanceFeatureFlag{ ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -131,7 +121,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), - Actions: gu.Ptr(true), ImprovedPerformance: nil, WebKey: gu.Ptr(true), OidcSingleV1SessionTermination: gu.Ptr(true), @@ -142,7 +131,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), - Actions: gu.Ptr(true), ImprovedPerformance: nil, WebKey: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), @@ -174,10 +162,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - Actions: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, TokenExchange: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -217,10 +201,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, }, - Actions: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, OidcTokenExchange: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_SYSTEM, diff --git a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go index 69e05352d0..cbd9f5f939 100644 --- a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go @@ -202,10 +202,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - Actions: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, }, }, { @@ -215,7 +211,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), }) require.NoError(t, err) }, @@ -236,10 +231,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - Actions: &feature.FeatureFlag{ - Enabled: true, - Source: feature.Source_SOURCE_INSTANCE, - }, }, }, { @@ -273,10 +264,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - Actions: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, }, }, } diff --git a/internal/api/grpc/server/middleware/execution_interceptor.go b/internal/api/grpc/server/middleware/execution_interceptor.go index 053386caae..4aeea6c4da 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor.go +++ b/internal/api/grpc/server/middleware/execution_interceptor.go @@ -5,12 +5,13 @@ import ( "encoding/json" "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/execution" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) func ExecutionHandler(queries *query.Queries) grpc.UnaryServerInterceptor { @@ -48,7 +49,7 @@ func executeTargetsForRequest(ctx context.Context, targets []execution.Target, f ProjectID: ctxData.ProjectID, OrgID: ctxData.OrgID, UserID: ctxData.UserID, - Request: req, + Request: Message{req.(proto.Message)}, } return execution.CallTargets(ctx, targets, info) @@ -70,8 +71,8 @@ func executeTargetsForResponse(ctx context.Context, targets []execution.Target, ProjectID: ctxData.ProjectID, OrgID: ctxData.OrgID, UserID: ctxData.UserID, - Request: req, - Response: resp, + Request: Message{req.(proto.Message)}, + Response: Message{resp.(proto.Message)}, } return execution.CallTargets(ctx, targets, info) @@ -80,12 +81,28 @@ func executeTargetsForResponse(ctx context.Context, targets []execution.Target, var _ execution.ContextInfo = &ContextInfoRequest{} type ContextInfoRequest struct { - FullMethod string `json:"fullMethod,omitempty"` - InstanceID string `json:"instanceID,omitempty"` - OrgID string `json:"orgID,omitempty"` - ProjectID string `json:"projectID,omitempty"` - UserID string `json:"userID,omitempty"` - Request interface{} `json:"request,omitempty"` + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` +} + +type Message struct { + proto.Message +} + +func (r *Message) MarshalJSON() ([]byte, error) { + data, err := protojson.Marshal(r.Message) + if err != nil { + return nil, err + } + return data, nil +} + +func (r *Message) UnmarshalJSON(data []byte) error { + return protojson.Unmarshal(data, r.Message) } func (c *ContextInfoRequest) GetHTTPRequestBody() []byte { @@ -97,26 +114,23 @@ func (c *ContextInfoRequest) GetHTTPRequestBody() []byte { } func (c *ContextInfoRequest) SetHTTPResponseBody(resp []byte) error { - if !json.Valid(resp) { - return zerrors.ThrowPreconditionFailed(nil, "ACTION-4m9s2", "Errors.Execution.ResponseIsNotValidJSON") - } - return json.Unmarshal(resp, c.Request) + return json.Unmarshal(resp, &c.Request) } func (c *ContextInfoRequest) GetContent() interface{} { - return c.Request + return c.Request.Message } var _ execution.ContextInfo = &ContextInfoResponse{} type ContextInfoResponse struct { - FullMethod string `json:"fullMethod,omitempty"` - InstanceID string `json:"instanceID,omitempty"` - OrgID string `json:"orgID,omitempty"` - ProjectID string `json:"projectID,omitempty"` - UserID string `json:"userID,omitempty"` - Request interface{} `json:"request,omitempty"` - Response interface{} `json:"response,omitempty"` + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` + Response Message `json:"response,omitempty"` } func (c *ContextInfoResponse) GetHTTPRequestBody() []byte { @@ -128,9 +142,9 @@ func (c *ContextInfoResponse) GetHTTPRequestBody() []byte { } func (c *ContextInfoResponse) SetHTTPResponseBody(resp []byte) error { - return json.Unmarshal(resp, c.Response) + return json.Unmarshal(resp, &c.Response) } func (c *ContextInfoResponse) GetContent() interface{} { - return c.Response + return c.Response.Message } diff --git a/internal/api/grpc/server/middleware/execution_interceptor_test.go b/internal/api/grpc/server/middleware/execution_interceptor_test.go index 6a5b74c5e4..281db4617a 100644 --- a/internal/api/grpc/server/middleware/execution_interceptor_test.go +++ b/internal/api/grpc/server/middleware/execution_interceptor_test.go @@ -11,6 +11,9 @@ import ( "time" "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/execution" @@ -54,28 +57,28 @@ func (e *mockExecutionTarget) GetSigningKey() string { return e.SigningKey } -type mockContentRequest struct { - Content string -} - -func newMockContentRequest(content string) *mockContentRequest { - return &mockContentRequest{ - Content: content, +func newMockContentRequest(content string) proto.Message { + return &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "content": { + Kind: &structpb.Value_StringValue{StringValue: content}, + }, + }, } } func newMockContextInfoRequest(fullMethod, request string) *ContextInfoRequest { return &ContextInfoRequest{ FullMethod: fullMethod, - Request: newMockContentRequest(request), + Request: Message{Message: newMockContentRequest(request)}, } } func newMockContextInfoResponse(fullMethod, request, response string) *ContextInfoResponse { return &ContextInfoResponse{ FullMethod: fullMethod, - Request: newMockContentRequest(request), - Response: newMockContentRequest(response), + Request: Message{Message: newMockContentRequest(request)}, + Response: Message{Message: newMockContentRequest(response)}, } } @@ -591,7 +594,7 @@ func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { } else { assert.NoError(t, err) } - assert.Equal(t, tt.res.want, resp) + assert.EqualExportedValues(t, tt.res.want, resp) for _, closeF := range closeFuncs { closeF() @@ -632,7 +635,7 @@ func testServerCall( time.Sleep(sleep) w.Header().Set("Content-Type", "application/json") - resp, err := json.Marshal(respBody) + resp, err := protojson.Marshal(respBody.(proto.Message)) if err != nil { http.Error(w, "error", http.StatusInternalServerError) return @@ -723,7 +726,8 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { statusCode: http.StatusOK, }, }, - req: []byte{}, + req: newMockContentRequest(""), + resp: newMockContentRequest(""), }, res{ wantErr: true, @@ -790,7 +794,7 @@ func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { } else { assert.NoError(t, err) } - assert.Equal(t, tt.res.want, resp) + assert.EqualExportedValues(t, tt.res.want, resp) for _, closeF := range closeFuncs { closeF() diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 3e927cc0c5..cb12bff828 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -21,7 +21,6 @@ type InstanceFeatures struct { LegacyIntrospection *bool UserSchema *bool TokenExchange *bool - Actions *bool ImprovedPerformance []feature.ImprovedPerformanceType WebKey *bool DebugOIDCParentError *bool @@ -39,7 +38,6 @@ func (m *InstanceFeatures) isEmpty() bool { m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && - m.Actions == nil && // nil check to allow unset improvements m.ImprovedPerformance == nil && m.WebKey == nil && diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 954c769304..977a46b6c2 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -71,7 +71,6 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, - feature_v2.InstanceActionsEventType, feature_v2.InstanceImprovedPerformanceEventType, feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, @@ -108,9 +107,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyUserSchema: v := value.(bool) features.UserSchema = &v - case feature.KeyActions: - v := value.(bool) - features.Actions = &v case feature.KeyImprovedPerformance: v := value.([]feature.ImprovedPerformanceType) features.ImprovedPerformance = v @@ -148,7 +144,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.InstanceLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.Actions, f.Actions, feature_v2.InstanceActionsEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.WebKey, f.WebKey, feature_v2.InstanceWebKeyEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DebugOIDCParentError, f.DebugOIDCParentError, feature_v2.InstanceDebugOIDCParentErrorEventType) diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go index e6b6bb4346..02e8896a0c 100644 --- a/internal/command/instance_features_test.go +++ b/internal/command/instance_features_test.go @@ -149,24 +149,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ResourceOwner: "instance1", }, }, - { - name: "set Actions", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceActionsEventType, true, - ), - ), - ), - args: args{ctx, &InstanceFeatures{ - Actions: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "instance1", - }, - }, { name: "push error", eventstore: expectEventstore( @@ -204,10 +186,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceUserSchemaEventType, true, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceActionsEventType, true, - ), feature_v2.NewSetEvent[bool]( ctx, aggregate, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, true, @@ -219,7 +197,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ diff --git a/internal/command/system_features.go b/internal/command/system_features.go index dc886de318..b317ea93bb 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -15,7 +15,6 @@ type SystemFeatures struct { LegacyIntrospection *bool TokenExchange *bool UserSchema *bool - Actions *bool ImprovedPerformance []feature.ImprovedPerformanceType OIDCSingleV1SessionTermination *bool DisableUserTokenEvent *bool @@ -30,7 +29,6 @@ func (m *SystemFeatures) isEmpty() bool { m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && - m.Actions == nil && // nil check to allow unset improvements m.ImprovedPerformance == nil && m.OIDCSingleV1SessionTermination == nil && diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index 15fc3e0bf0..28e56f8bd4 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -64,7 +64,6 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, - feature_v2.SystemActionsEventType, feature_v2.SystemImprovedPerformanceEventType, feature_v2.SystemOIDCSingleV1SessionTerminationEventType, feature_v2.SystemDisableUserTokenEvent, @@ -98,9 +97,6 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyTokenExchange: v := value.(bool) features.TokenExchange = &v - case feature.KeyActions: - v := value.(bool) - features.Actions = &v case feature.KeyImprovedPerformance: features.ImprovedPerformance = value.([]feature.ImprovedPerformanceType) case feature.KeyOIDCSingleV1SessionTermination: @@ -128,7 +124,6 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.Actions, f.Actions, feature_v2.SystemActionsEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.SystemOIDCSingleV1SessionTerminationEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.SystemDisableUserTokenEvent) diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go index 9c5f4cc2a9..b1b5207b8c 100644 --- a/internal/command/system_features_test.go +++ b/internal/command/system_features_test.go @@ -117,24 +117,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ResourceOwner: "SYSTEM", }, }, - { - name: "set Actions", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemActionsEventType, true, - ), - ), - ), - args: args{context.Background(), &SystemFeatures{ - Actions: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "SYSTEM", - }, - }, { name: "push error", eventstore: expectEventstore( @@ -172,10 +154,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemActionsEventType, true, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemOIDCSingleV1SessionTerminationEventType, true, @@ -187,7 +165,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), - Actions: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ @@ -233,10 +210,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemActionsEventType, false, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemOIDCSingleV1SessionTerminationEventType, false, @@ -248,7 +221,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { TriggerIntrospectionProjections: gu.Ptr(false), LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), - Actions: gu.Ptr(false), OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ diff --git a/internal/execution/execution.go b/internal/execution/execution.go index 575c86ecc4..b885858d94 100644 --- a/internal/execution/execution.go +++ b/internal/execution/execution.go @@ -82,11 +82,11 @@ func CallTarget( case domain.TargetTypeCall: return Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()) case domain.TargetTypeAsync: - go func(target Target, info ContextInfoRequest) { - if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info.GetHTTPRequestBody(), target.GetSigningKey()); err != nil { + go func(ctx context.Context, target Target, info []byte) { + if _, err := Call(ctx, target.GetEndpoint(), target.GetTimeout(), info, target.GetSigningKey()); err != nil { logging.WithFields("target", target.GetTargetID()).OnError(err).Info(err) } - }(target, info) + }(context.WithoutCancel(ctx), target, info.GetHTTPRequestBody()) return nil, nil default: return nil, zerrors.ThrowInternal(nil, "EXEC-auqnansr2m", "Errors.Execution.Unknown") diff --git a/internal/execution/execution_test.go b/internal/execution/execution_test.go index 40731a840a..036b160ab7 100644 --- a/internal/execution/execution_test.go +++ b/internal/execution/execution_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/api/grpc/server/middleware" "github.com/zitadel/zitadel/internal/domain" @@ -149,8 +150,8 @@ func Test_CallTarget(t *testing.T) { info: requestContextInfo1, server: &callTestServer{ method: http.MethodPost, - expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), - respondBody: []byte("{\"request\":\"content2\"}"), + expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + respondBody: []byte("{\"content\":\"request2\"}"), timeout: time.Second, statusCode: http.StatusInternalServerError, }, @@ -170,8 +171,8 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), - respondBody: []byte("{\"request\":\"content2\"}"), + expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusInternalServerError, }, target: &mockTarget{ @@ -191,8 +192,8 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), - respondBody: []byte("{\"request\":\"content2\"}"), + expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, }, target: &mockTarget{ @@ -212,8 +213,8 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), - respondBody: []byte("{\"request\":\"content2\"}"), + expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, signingKey: "signingkey", }, @@ -235,8 +236,8 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), - respondBody: []byte("{\"request\":\"content2\"}"), + expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusInternalServerError, }, target: &mockTarget{ @@ -256,8 +257,8 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), - respondBody: []byte("{\"request\":\"content2\"}"), + expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, }, target: &mockTarget{ @@ -266,7 +267,7 @@ func Test_CallTarget(t *testing.T) { }, }, res{ - body: []byte("{\"request\":\"content2\"}"), + body: []byte("{\"content\":\"request2\"}"), }, }, { @@ -277,8 +278,8 @@ func Test_CallTarget(t *testing.T) { server: &callTestServer{ timeout: time.Second, method: http.MethodPost, - expectBody: []byte("{\"request\":{\"request\":\"content1\"}}"), - respondBody: []byte("{\"request\":\"content2\"}"), + expectBody: []byte("{\"request\":{\"content\":\"request1\"}}"), + respondBody: []byte("{\"content\":\"request2\"}"), statusCode: http.StatusOK, signingKey: "signingkey", }, @@ -289,7 +290,7 @@ func Test_CallTarget(t *testing.T) { }, }, res{ - body: []byte("{\"request\":\"content2\"}"), + body: []byte("{\"content\":\"request2\"}"), }, }, } @@ -576,13 +577,13 @@ func testCallTargets(ctx context.Context, } var requestContextInfo1 = &middleware.ContextInfoRequest{ - Request: &request{ - Request: "content1", - }, + Request: middleware.Message{Message: &structpb.Struct{ + Fields: map[string]*structpb.Value{"content": structpb.NewStringValue("request1")}, + }}, } -var requestContextInfoBody1 = []byte("{\"request\":{\"request\":\"content1\"}}") -var requestContextInfoBody2 = []byte("{\"request\":{\"request\":\"content2\"}}") +var requestContextInfoBody1 = []byte("{\"request\":{\"content\":\"request1\"}}") +var requestContextInfoBody2 = []byte("{\"request\":{\"content\":\"request2\"}}") type request struct { Request string `json:"request"` diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 638917fd68..389b750483 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -15,7 +15,7 @@ const ( KeyLegacyIntrospection KeyUserSchema KeyTokenExchange - KeyActions + KeyActionsDeprecated KeyImprovedPerformance KeyWebKey KeyDebugOIDCParentError @@ -46,7 +46,6 @@ type Features struct { LegacyIntrospection bool `json:"legacy_introspection,omitempty"` UserSchema bool `json:"user_schema,omitempty"` TokenExchange bool `json:"token_exchange,omitempty"` - Actions bool `json:"actions,omitempty"` ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` WebKey bool `json:"web_key,omitempty"` DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index 5a37b96270..6466061718 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -30,7 +30,7 @@ func _KeyNoOp() { _ = x[KeyLegacyIntrospection-(3)] _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] - _ = x[KeyActions-(6)] + _ = x[KeyActionsDeprecated-(6)] _ = x[KeyImprovedPerformance-(7)] _ = x[KeyWebKey-(8)] _ = x[KeyDebugOIDCParentError-(9)] @@ -42,7 +42,7 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActions, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActionsDeprecated, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ _KeyName[0:11]: KeyUnspecified, @@ -57,8 +57,8 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[81:92]: KeyUserSchema, _KeyName[92:106]: KeyTokenExchange, _KeyLowerName[92:106]: KeyTokenExchange, - _KeyName[106:113]: KeyActions, - _KeyLowerName[106:113]: KeyActions, + _KeyName[106:113]: KeyActionsDeprecated, + _KeyLowerName[106:113]: KeyActionsDeprecated, _KeyName[113:133]: KeyImprovedPerformance, _KeyLowerName[113:133]: KeyImprovedPerformance, _KeyName[133:140]: KeyWebKey, diff --git a/internal/integration/action.go b/internal/integration/action.go index b8f69c5788..e849b5c21c 100644 --- a/internal/integration/action.go +++ b/internal/integration/action.go @@ -8,6 +8,9 @@ import ( "reflect" "sync" "time" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" ) type server struct { @@ -100,3 +103,61 @@ func TestServerCall( server.server = httptest.NewServer(http.HandlerFunc(handler)) return server.URL(), server.Close, server.Called, server.ResetCalled } + +func TestServerCallProto( + reqBody interface{}, + sleep time.Duration, + statusCode int, + respBody proto.Message, +) (url string, closeF func(), calledF func() int, resetCalledF func()) { + server := &server{ + called: 0, + } + + handler := func(w http.ResponseWriter, r *http.Request) { + server.Increase() + if reqBody != nil { + data, err := json.Marshal(reqBody) + if err != nil { + http.Error(w, "error, marshall: "+err.Error(), http.StatusInternalServerError) + return + } + sentBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "error, read body: "+err.Error(), http.StatusInternalServerError) + return + } + if !reflect.DeepEqual(data, sentBody) { + http.Error(w, "error, equal:\n"+string(data)+"\nsent:\n"+string(sentBody), http.StatusInternalServerError) + return + } + } + if statusCode != http.StatusOK { + http.Error(w, "error, statusCode", statusCode) + return + } + + time.Sleep(sleep) + + if respBody != nil { + w.Header().Set("Content-Type", "application/json") + resp, err := protojson.Marshal(respBody) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if _, err := io.Writer.Write(w, resp); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + } else { + if _, err := io.WriteString(w, "finished successfully"); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + } + } + + server.server = httptest.NewServer(http.HandlerFunc(handler)) + return server.URL(), server.Close, server.Called, server.ResetCalled +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 2cb8fa3641..e82a6bec55 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -763,7 +763,7 @@ func (i *Instance) DeleteExecution(ctx context.Context, t *testing.T, cond *acti require.NoError(t, err) } -func (i *Instance) SetExecution(ctx context.Context, t *testing.T, cond *action.Condition, targets []*action.ExecutionTargetType) *action.SetExecutionResponse { +func (i *Instance) SetExecution(ctx context.Context, t *testing.T, cond *action.Condition, targets []string) *action.SetExecutionResponse { target, err := i.Client.ActionV2beta.SetExecution(ctx, &action.SetExecutionRequest{ Condition: cond, Targets: targets, diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 911edaa606..4ec40dc9d5 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -14,7 +14,6 @@ type InstanceFeatures struct { LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] - Actions FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] WebKey FeatureSource[bool] DebugOIDCParentError FeatureSource[bool] diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 11deb30f34..6a0abbb58c 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -67,7 +67,6 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, - feature_v2.InstanceActionsEventType, feature_v2.InstanceImprovedPerformanceEventType, feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, @@ -98,7 +97,6 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { m.instance.LegacyIntrospection = m.system.LegacyIntrospection m.instance.UserSchema = m.system.UserSchema m.instance.TokenExchange = m.system.TokenExchange - m.instance.Actions = m.system.Actions m.instance.ImprovedPerformance = m.system.ImprovedPerformance m.instance.OIDCSingleV1SessionTermination = m.system.OIDCSingleV1SessionTermination m.instance.DisableUserTokenEvent = m.system.DisableUserTokenEvent @@ -113,7 +111,8 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ return err } switch key { - case feature.KeyUnspecified: + case feature.KeyUnspecified, + feature.KeyActionsDeprecated: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) @@ -125,8 +124,6 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: features.TokenExchange.set(level, event.Value) - case feature.KeyActions: - features.Actions.set(level, event.Value) case feature.KeyImprovedPerformance: features.ImprovedPerformance.set(level, event.Value) case feature.KeyWebKey: diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go index 903c2872a9..d80a3b05fc 100644 --- a/internal/query/instance_features_test.go +++ b/internal/query/instance_features_test.go @@ -105,10 +105,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceActionsEventType, false, - )), ), ), args: args{true}, @@ -132,10 +128,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: false, }, - Actions: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: false, - }, }, }, { @@ -162,10 +154,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceActionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( ctx, aggregate, feature_v2.InstanceResetEventType, @@ -197,10 +185,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - Actions: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, { @@ -223,10 +207,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceActionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( ctx, aggregate, feature_v2.InstanceResetEventType, @@ -258,10 +238,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - Actions: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, } diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 798af6693c..34100a0d66 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -80,10 +80,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceTokenExchangeEventType, Reduce: reduceInstanceSetFeature[bool], }, - { - Event: feature_v2.InstanceActionsEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceImprovedPerformanceEventType, Reduce: reduceInstanceSetFeature[[]feature.ImprovedPerformanceType], diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index f6f0a36d56..de54054e78 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -72,10 +72,6 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemTokenExchangeEventType, Reduce: reduceSystemSetFeature[bool], }, - { - Event: feature_v2.SystemActionsEventType, - Reduce: reduceSystemSetFeature[bool], - }, { Event: feature_v2.SystemImprovedPerformanceEventType, Reduce: reduceSystemSetFeature[[]feature.ImprovedPerformanceType], diff --git a/internal/query/system_features.go b/internal/query/system_features.go index 31ad402d12..dcbbb7d6fe 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -25,7 +25,6 @@ type SystemFeatures struct { LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] - Actions FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] OIDCSingleV1SessionTermination FeatureSource[bool] DisableUserTokenEvent FeatureSource[bool] diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index 217154e3ed..69e1f35968 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -60,7 +60,6 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, - feature_v2.SystemActionsEventType, feature_v2.SystemImprovedPerformanceEventType, feature_v2.SystemOIDCSingleV1SessionTerminationEventType, feature_v2.SystemDisableUserTokenEvent, @@ -82,7 +81,8 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S return err } switch key { - case feature.KeyUnspecified: + case feature.KeyUnspecified, + feature.KeyActionsDeprecated: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) @@ -94,8 +94,6 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: features.TokenExchange.set(level, event.Value) - case feature.KeyActions: - features.Actions.set(level, event.Value) case feature.KeyImprovedPerformance: features.ImprovedPerformance.set(level, event.Value) case feature.KeyOIDCSingleV1SessionTermination: diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go index fcd0f812f5..5a58ac23d7 100644 --- a/internal/query/system_features_test.go +++ b/internal/query/system_features_test.go @@ -61,10 +61,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemActionsEventType, true, - )), ), ), want: &SystemFeatures{ @@ -87,10 +83,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: false, }, - Actions: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, }, }, { @@ -113,10 +105,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemActionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( context.Background(), aggregate, feature_v2.SystemResetEventType, @@ -147,10 +135,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - Actions: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, { @@ -173,10 +157,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemActionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( context.Background(), aggregate, feature_v2.SystemResetEventType, @@ -207,10 +187,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - Actions: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, } diff --git a/internal/repository/execution/queue.go b/internal/repository/execution/queue.go index 28f8edbf31..ed3a6ce4a0 100644 --- a/internal/repository/execution/queue.go +++ b/internal/repository/execution/queue.go @@ -41,16 +41,16 @@ func ContextInfoFromRequest(e *Request) *ContextInfoEvent { } type ContextInfoEvent struct { - AggregateID string `json:"aggregateID,omitempty"` - AggregateType string `json:"aggregateType,omitempty"` - ResourceOwner string `json:"resourceOwner,omitempty"` - InstanceID string `json:"instanceID,omitempty"` - Version string `json:"version,omitempty"` - Sequence uint64 `json:"sequence,omitempty"` - EventType string `json:"event_type,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UserID string `json:"userID,omitempty"` - EventPayload []byte `json:"event_payload,omitempty"` + AggregateID string `json:"aggregateID,omitempty"` + AggregateType string `json:"aggregateType,omitempty"` + ResourceOwner string `json:"resourceOwner,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + Version string `json:"version,omitempty"` + Sequence uint64 `json:"sequence,omitempty"` + EventType string `json:"event_type,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UserID string `json:"userID,omitempty"` + EventPayload json.RawMessage `json:"event_payload,omitempty"` } func (c *ContextInfoEvent) GetHTTPRequestBody() []byte { diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index e8d0da1ab0..00618f56c2 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -12,7 +12,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, SystemActionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) @@ -26,7 +25,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceActionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceWebKeyEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDebugOIDCParentErrorEventType, eventstore.GenericEventMapper[SetEvent[bool]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index 008986824b..d5e8941df2 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -17,7 +17,6 @@ var ( SystemLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLegacyIntrospection) SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) - SystemActionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyActions) SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) @@ -31,7 +30,6 @@ var ( InstanceLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLegacyIntrospection) InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) - InstanceActionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyActions) InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) InstanceWebKeyEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyWebKey) InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) diff --git a/proto/buf.yaml b/proto/buf.yaml index f8cf192a95..31bc7b4ccc 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -7,6 +7,10 @@ deps: breaking: use: - FILE + - FIELD_NO_DELETE_UNLESS_NAME_RESERVED + - FIELD_NO_DELETE_UNLESS_NUMBER_RESERVED + except: + - FIELD_NO_DELETE ignore_unstable_packages: true lint: use: diff --git a/proto/zitadel/action/v2beta/action_service.proto b/proto/zitadel/action/v2beta/action_service.proto index d1eebfa344..f225905225 100644 --- a/proto/zitadel/action/v2beta/action_service.proto +++ b/proto/zitadel/action/v2beta/action_service.proto @@ -670,8 +670,8 @@ message ListTargetsResponse { message SetExecutionRequest { // Condition defining when the execution should be used. Condition condition = 1; - // Ordered list of targets/includes called during the execution. - repeated ExecutionTargetType targets = 2; + // Ordered list of targets called during the execution. + repeated string targets = 2; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { example: "{\"condition\":{\"request\":{\"method\":\"zitadel.session.v2.SessionService/ListSessions\"}},\"targets\":[{\"target\":\"69629026806489455\"}]}"; }; diff --git a/proto/zitadel/action/v2beta/execution.proto b/proto/zitadel/action/v2beta/execution.proto index 4f4ad1ce88..e93470e5dc 100644 --- a/proto/zitadel/action/v2beta/execution.proto +++ b/proto/zitadel/action/v2beta/execution.proto @@ -29,18 +29,8 @@ message Execution { example: "\"2025-01-23T10:34:18.051Z\""; } ]; - // Ordered list of targets/includes called during the execution. - repeated ExecutionTargetType targets = 4; -} - -message ExecutionTargetType { - oneof type { - option (validate.required) = true; - // Unique identifier of existing target to call. - string target = 1; - // Unique identifier of existing execution to include targets of. - Condition include = 2; - } + // Ordered list of targets called during the execution. + repeated string targets = 4; } message Condition { diff --git a/proto/zitadel/action/v2beta/query.proto b/proto/zitadel/action/v2beta/query.proto index 564db8bc9f..fe4f72f294 100644 --- a/proto/zitadel/action/v2beta/query.proto +++ b/proto/zitadel/action/v2beta/query.proto @@ -19,7 +19,6 @@ message ExecutionSearchFilter { InConditionsFilter in_conditions_filter = 1; ExecutionTypeFilter execution_type_filter = 2; TargetFilter target_filter = 3; - IncludeFilter include_filter = 4; } } @@ -43,16 +42,6 @@ message TargetFilter { ]; } -message IncludeFilter { - // Defines the include to query for. - Condition include = 1 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "the id of the include" - example: "\"request.zitadel.session.v2.SessionService\""; - } - ]; -} - enum TargetFieldName { TARGET_FIELD_NAME_UNSPECIFIED = 0; TARGET_FIELD_NAME_ID = 1; diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index efd7f83e4c..fe8d3f7a39 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,6 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ + reserved 6; + reserved "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -43,12 +45,6 @@ message SetInstanceFeaturesRequest{ description: "Enable the experimental `urn:ietf:params:oauth:grant-type:token-exchange` grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance."; } ]; - optional bool actions = 6 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; repeated ImprovedPerformance improved_performance = 7 [ (validate.rules).repeated.unique = true, @@ -135,6 +131,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { + reserved 7; + reserved "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -171,13 +169,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag actions = 7 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions v2 allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; - ImprovedPerformanceFeatureFlag improved_performance = 8 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[1]"; diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index c734905fb2..d222e2a90c 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -11,6 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetSystemFeaturesRequest{ + reserved 6; + reserved "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -46,13 +48,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool actions = 6 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; - repeated ImprovedPerformance improved_performance = 7 [ (validate.rules).repeated.unique = true, (validate.rules).repeated.items.enum = {defined_only: true, not_in: [0]}, @@ -110,6 +105,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { + reserved 7; + reserved "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -146,13 +143,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag actions = 7 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions v2 allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; - ImprovedPerformanceFeatureFlag improved_performance = 8 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[1]"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 865a1d2308..7717dd7556 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,6 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ + reserved 6; + reserved "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -43,12 +45,6 @@ message SetInstanceFeaturesRequest{ description: "Enable the experimental `urn:ietf:params:oauth:grant-type:token-exchange` grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance."; } ]; - optional bool actions = 6 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; repeated ImprovedPerformance improved_performance = 7 [ (validate.rules).repeated.unique = true, @@ -101,6 +97,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { + reserved 7; + reserved "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -137,13 +135,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag actions = 7 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions v2 allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; - ImprovedPerformanceFeatureFlag improved_performance = 8 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[1]"; diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index 98b37ad893..624e68ec79 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -11,6 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetSystemFeaturesRequest{ + reserved 6; + reserved "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -46,13 +48,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool actions = 6 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; - repeated ImprovedPerformance improved_performance = 7 [ (validate.rules).repeated.unique = true, (validate.rules).repeated.items.enum = {defined_only: true, not_in: [0]}, @@ -83,6 +78,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { + reserved 7; + reserved "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -119,13 +116,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag actions = 7 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Actions v2 allow to manage data executions and targets. If the flag is enabled, you'll be able to use the new API and its features. Note that it is still in an early stage."; - } - ]; - ImprovedPerformanceFeatureFlag improved_performance = 8 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[1]"; From a9dd78a13269eb57435a995f17ef05cc07d8ddc2 Mon Sep 17 00:00:00 2001 From: Allen Oyieke Date: Mon, 28 Apr 2025 12:53:31 +0300 Subject: [PATCH 020/123] docs: fix typo in Java SDK example document (#9804) # Which Problems Are Solved This PR resolves the issue #9648 # How the Problems Are Solved Resolves a typo in the documentation # Additional Context - Closes #9648 - Discussion #9648 --- docs/docs/sdk-examples/java.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/sdk-examples/java.mdx b/docs/docs/sdk-examples/java.mdx index e7afb5188b..e4862422d0 100644 --- a/docs/docs/sdk-examples/java.mdx +++ b/docs/docs/sdk-examples/java.mdx @@ -41,7 +41,7 @@ The following features are covered by Java Spring Security: The goal is to have a ZITADEL Java SDK in the future which will cover the following: - Wrapper around Java Spring Security - Authentication with OIDC -- Authorization and checking Rolls +- Authorization and checking Roles - Integrate ZITADEL APIs to read and manage resources - Integrate ZITADEL Session API to create your own login UI From 205beb607b35a08cac07ee6abdb9615db484e614 Mon Sep 17 00:00:00 2001 From: intelli-joe <148788305+intelli-joe@users.noreply.github.com> Date: Mon, 28 Apr 2025 08:22:04 -0500 Subject: [PATCH 021/123] fix: update link to postgres-insecure example in docs (#9802) Fix reference to postgres-insecure example in docs --- docs/docs/self-hosting/manage/configure/_helm.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/self-hosting/manage/configure/_helm.mdx b/docs/docs/self-hosting/manage/configure/_helm.mdx index b35957abb8..17fb8165a6 100644 --- a/docs/docs/self-hosting/manage/configure/_helm.mdx +++ b/docs/docs/self-hosting/manage/configure/_helm.mdx @@ -3,4 +3,4 @@ Configure Zitadel using native Helm values. You can manage secrets through Helm values, letting Helm create Kubernetes secrets. Alternatively, reference existing Kubernetes secrets managed outside of Helm. See the [referenced secrets example](https://github.com/zitadel/zitadel-charts/tree/main/examples/3-referenced-secrets) in the charts */examples* folder. -For a quick setup, check out the [insecure Postgres example](https://github.com/zitadel/zitadel-charts/tree/main/examples/1-insecure-postgres). +For a quick setup, check out the [insecure Postgres example](https://github.com/zitadel/zitadel-charts/tree/main/examples/1-postgres-insecure). From ed4e226da923f4caf49067db8ab2934d849f9ad9 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Tue, 29 Apr 2025 11:12:43 +0200 Subject: [PATCH 022/123] fix(defaults): comment default SystemAPIUsers (#9813) # Which Problems Are Solved If I start a fresh instance and do not overwrite `SystemAPIUsers` I get an error during startup `error="decoding failed due to the following error(s):\n\n'SystemAPIUsers[0][path]' expected a map, got 'string'\n'SystemAPIUsers[0][memberships]' expected a map, got 'slice'"` # How the Problems Are Solved the configuration is commented so that the example is still there # Additional Changes - # Additional Context was added in https://github.com/zitadel/zitadel/pull/9757 --- cmd/defaults.yaml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 8482ccec9f..55e14bbada 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -607,14 +607,14 @@ EncryptionKeys: UserAgentCookieKeyID: "userAgentCookieKey" # ZITADEL_ENCRYPTIONKEYS_USERAGENTCOOKIEKEYID SystemAPIUsers: - - superuser: - Path: /path/to/superuser/key.pem - Memberships: - - MemberType: Organization - Roles: "ORG_OWNER" - AggregateID: "123456789012345678" - - MemberType: Project - Roles: "PROJECT_OWNER" + # - superuser: + # Path: /path/to/superuser/key.pem + # Memberships: + # - MemberType: Organization + # Roles: "ORG_OWNER" + # AggregateID: "123456789012345678" + # - MemberType: Project + # Roles: "PROJECT_OWNER" # # Add keys for authentication of the systemAPI here: From ce823c9176bcd64ff3aacf73ce5fcb192dca76ce Mon Sep 17 00:00:00 2001 From: David Skewis Date: Tue, 29 Apr 2025 10:42:49 +0100 Subject: [PATCH 023/123] fix: update session recordings for posthog (#9775) # Which Problems Are Solved - Updates to only capture 10% of events with posthog # How the Problems Are Solved - Uses a feature flag rolled out to 10% of users to enable the capture # Additional Changes N/A # Additional Context N/A --- console/src/app/services/posthog.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/console/src/app/services/posthog.service.ts b/console/src/app/services/posthog.service.ts index 2f9630282a..17855d7eb5 100644 --- a/console/src/app/services/posthog.service.ts +++ b/console/src/app/services/posthog.service.ts @@ -26,8 +26,16 @@ export class PosthogService implements OnDestroy { maskAllInputs: true, maskTextSelector: '*', }, + disable_session_recording: true, enable_heatmaps: true, persistence: 'memory', + loaded: (posthog) => { + posthog.onFeatureFlags((flags) => { + if (posthog.isFeatureEnabled('session_recording')) { + posthog.startSessionRecording(); + } + }); + }, }); } } From d930a09cb04012cac96610f281401fe6d094d030 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 29 Apr 2025 13:25:49 +0200 Subject: [PATCH 024/123] fix: Improve Actions V2 Texts and reenable in settings (#9814) # Which Problems Are Solved This pr includes improved texts to make the usage of Actions V2 more easy. Since the removal of the Actions V2 Feature Flag we removed the code that checks if it's enabled in the settings sidenav. # How the Problems Are Solved Added new texts to translations. Removed sidenav logic that checks for Actions V2 Feature Flag # Additional Context - Part of #7248 - Part of #9688 --------- Co-authored-by: Max Peintner Co-authored-by: Max Peintner --- console/package.json | 4 +- .../actions-two-actions-table.component.ts | 4 +- .../actions-two-actions.component.html | 3 + .../actions-two-actions.component.ts | 3 + ...actions-two-add-action-dialog.component.ts | 19 +--- ...tions-two-add-action-target.component.html | 14 +-- ...actions-two-add-action-target.component.ts | 92 ++++++++++--------- ...tions-two-add-target-dialog.component.html | 3 + ...tions-two-add-target-dialog.component.scss | 4 + .../actions-two-targets.component.html | 3 + .../actions-two-targets.component.ts | 6 +- .../modules/actions-two/actions-two.module.ts | 2 + .../src/app/modules/settings-list/settings.ts | 2 + .../modules/sidenav/sidenav.component.html | 1 + .../modules/sidenav/sidenav.component.scss | 4 + .../app/modules/sidenav/sidenav.component.ts | 1 + .../app/pages/actions/actions.component.html | 3 + .../app/pages/actions/actions.component.ts | 63 ++++++------- .../app/pages/instance/instance.component.ts | 30 +----- console/src/assets/i18n/bg.json | 8 +- console/src/assets/i18n/cs.json | 8 +- console/src/assets/i18n/de.json | 8 +- console/src/assets/i18n/en.json | 8 +- console/src/assets/i18n/es.json | 8 +- console/src/assets/i18n/fr.json | 8 +- console/src/assets/i18n/hu.json | 8 +- console/src/assets/i18n/id.json | 8 +- console/src/assets/i18n/it.json | 8 +- console/src/assets/i18n/ja.json | 8 +- console/src/assets/i18n/ko.json | 8 +- console/src/assets/i18n/mk.json | 8 +- console/src/assets/i18n/nl.json | 8 +- console/src/assets/i18n/pl.json | 8 +- console/src/assets/i18n/pt.json | 8 +- console/src/assets/i18n/ro.json | 8 +- console/src/assets/i18n/ru.json | 8 +- console/src/assets/i18n/sv.json | 8 +- console/src/assets/i18n/zh.json | 8 +- console/yarn.lock | 25 ++--- 39 files changed, 249 insertions(+), 189 deletions(-) diff --git a/console/package.json b/console/package.json index 2d986730c2..77a8a40147 100644 --- a/console/package.json +++ b/console/package.json @@ -31,8 +31,8 @@ "@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/free-brands-svg-icons": "^6.7.2", "@ngx-translate/core": "^15.0.0", - "@zitadel/client": "^1.0.7", - "@zitadel/proto": "1.0.5-sha-4118a9d", + "@zitadel/client": "1.2.0", + "@zitadel/proto": "1.2.0", "angular-oauth2-oidc": "^15.0.1", "angularx-qrcode": "^16.0.2", "buffer": "^6.0.3", diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts index 2d9942c406..658c205c4e 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts @@ -56,9 +56,9 @@ export class ActionsTwoActionsTableComponent { return executions.map((execution) => { const mappedTargets = execution.targets.map((target) => { - const targetType = targetsMap.get(target.type.value); + const targetType = targetsMap.get(target); if (!targetType) { - throw new Error(`Target with id ${target.type.value} not found`); + throw new Error(`Target with id ${target} not found`); } return targetType; }); diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html index c22b03ef76..3e6c31fc0e 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.html @@ -1,4 +1,7 @@

{{ 'ACTIONSTWO.EXECUTION.TITLE' | translate }}

+ + {{ 'ACTIONSTWO.BETA_NOTE' | translate }} +

{{ 'ACTIONSTWO.EXECUTION.DESCRIPTION' | translate }}

}; -type CorrectlyTypedTargets = { type: Extract }; - -export type CorrectlyTypedExecution = Omit & { +export type CorrectlyTypedExecution = Omit & { condition: CorrectlyTypedCondition; - targets: CorrectlyTypedTargets[]; }; export const correctlyTypeExecution = (execution: Execution): CorrectlyTypedExecution => { @@ -48,9 +40,6 @@ export const correctlyTypeExecution = (execution: Execution): CorrectlyTypedExec return { ...execution, condition, - targets: execution.targets - .map(({ type }) => ({ type })) - .filter((target): target is CorrectlyTypedTargets => target.type.case === 'target'), }; }; @@ -81,7 +70,7 @@ export class ActionTwoAddActionDialogComponent { protected readonly typeSignal = signal('request'); protected readonly conditionSignal = signal['condition']>(undefined); - protected readonly targetsSignal = signal[]>([]); + protected readonly targetsSignal = signal([]); protected readonly continueSubject = new Subject(); @@ -112,7 +101,7 @@ export class ActionTwoAddActionDialogComponent { this.targetsSignal.set(data.execution.targets); this.typeSignal.set(data.execution.condition.conditionType.case); this.conditionSignal.set(data.execution.condition); - this.preselectedTargetIds = data.execution.targets.map((target) => target.type.value); + this.preselectedTargetIds = data.execution.targets; this.page.set(Page.Target); // Set the initial page based on the provided execution data } diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html index 422ed7991e..26d9e3be2c 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.html @@ -1,4 +1,4 @@ - +

{{ 'ACTIONSTWO.EXECUTION.DIALOG.TARGET.DESCRIPTION' | translate }}

@@ -8,9 +8,9 @@ #trigger="matAutocompleteTrigger" #input type="text" - [formControl]="form().controls.autocomplete" + [formControl]="form.controls.autocomplete" [matAutocomplete]="autoservice" - (keydown.enter)="handleEnter($event); input.blur(); trigger.closePanel()" + (keydown.enter)="handleEnter($event, form); input.blur(); trigger.closePanel()" /> @@ -19,7 +19,7 @@ {{ target.name }} @@ -27,7 +27,7 @@ -
+
diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts index 658c205c4e..af9673dbf5 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.ts @@ -55,13 +55,9 @@ export class ActionsTwoActionsTableComponent { } return executions.map((execution) => { - const mappedTargets = execution.targets.map((target) => { - const targetType = targetsMap.get(target); - if (!targetType) { - throw new Error(`Target with id ${target} not found`); - } - return targetType; - }); + const mappedTargets = execution.targets + .map((target) => targetsMap.get(target)) + .filter((target): target is NonNullable => !!target); return { execution, mappedTargets }; }); }); diff --git a/console/src/app/modules/settings-list/settings.ts b/console/src/app/modules/settings-list/settings.ts index c96431fa30..7ec7fdea15 100644 --- a/console/src/app/modules/settings-list/settings.ts +++ b/console/src/app/modules/settings-list/settings.ts @@ -228,8 +228,7 @@ export const ACTIONS: SidenavSetting = { i18nKey: 'SETTINGS.LIST.ACTIONS', groupI18nKey: 'SETTINGS.GROUPS.ACTIONS', requiredRoles: { - // todo: figure out roles - [PolicyComponentServiceType.ADMIN]: ['iam.policy.read'], + [PolicyComponentServiceType.ADMIN]: ['action.execution.write', 'action.target.write'], }, beta: true, }; @@ -239,8 +238,7 @@ export const ACTIONS_TARGETS: SidenavSetting = { i18nKey: 'SETTINGS.LIST.TARGETS', groupI18nKey: 'SETTINGS.GROUPS.ACTIONS', requiredRoles: { - // todo: figure out roles - [PolicyComponentServiceType.ADMIN]: ['iam.policy.read'], + [PolicyComponentServiceType.ADMIN]: ['action.execution.write', 'action.target.write'], }, beta: true, }; diff --git a/console/src/app/services/grpc-auth.service.ts b/console/src/app/services/grpc-auth.service.ts index 3967f1df06..198d048b6a 100644 --- a/console/src/app/services/grpc-auth.service.ts +++ b/console/src/app/services/grpc-auth.service.ts @@ -1,7 +1,18 @@ import { Injectable } from '@angular/core'; import { SortDirection } from '@angular/material/sort'; import { OAuthService } from 'angular-oauth2-oidc'; -import { BehaviorSubject, combineLatestWith, EMPTY, mergeWith, NEVER, Observable, of, shareReplay, Subject } from 'rxjs'; +import { + BehaviorSubject, + combineLatestWith, + EMPTY, + identity, + mergeWith, + NEVER, + Observable, + of, + shareReplay, + Subject, +} from 'rxjs'; import { catchError, distinctUntilChanged, filter, finalize, map, startWith, switchMap, tap, timeout } from 'rxjs/operators'; import { @@ -326,7 +337,7 @@ export class GrpcAuthService { return new RegExp(reqRegexp).test(role); }); - const allCheck = requestedRoles.map(test).every((x) => !!x); + const allCheck = requestedRoles.map(test).every(identity); const oneCheck = requestedRoles.some(test); return requiresAll ? allCheck : oneCheck; From a05f7ce3fc864358ce3ef5cdd93386162eac5b25 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 30 Apr 2025 14:58:10 +0200 Subject: [PATCH 033/123] fix: correct handling of removed targets (#9824) # Which Problems Are Solved In Actions v2, if a target is removed, which is still used in an execution, the target is still listed when list executions. # How the Problems Are Solved Removed targets are now also removed from the executions. # Additional Changes To be sure the list executions include a check if the target is still existing. # Additional Context None Co-authored-by: Livio Spring --- internal/query/execution.go | 12 ++-- internal/query/execution_targets.sql | 22 ++++--- internal/query/execution_test.go | 71 +++++++++++++++++++-- internal/query/projection/execution.go | 25 ++++++++ internal/query/projection/execution_test.go | 30 +++++++++ 5 files changed, 141 insertions(+), 19 deletions(-) diff --git a/internal/query/execution.go b/internal/query/execution.go index 0a2a989918..4739a5839e 100644 --- a/internal/query/execution.go +++ b/internal/query/execution.go @@ -1,11 +1,13 @@ package query import ( + "cmp" "context" "database/sql" _ "embed" "encoding/json" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -301,13 +303,15 @@ func executionTargetsUnmarshal(data []byte) ([]*exec.Target, error) { } targets := make([]*exec.Target, len(executionTargets)) - // position starts with 1 - for _, item := range executionTargets { + slices.SortFunc(executionTargets, func(a, b *executionTarget) int { + return cmp.Compare(a.Position, b.Position) + }) + for i, item := range executionTargets { if item.Target != "" { - targets[item.Position-1] = &exec.Target{Type: domain.ExecutionTargetTypeTarget, Target: item.Target} + targets[i] = &exec.Target{Type: domain.ExecutionTargetTypeTarget, Target: item.Target} } if item.Include != "" { - targets[item.Position-1] = &exec.Target{Type: domain.ExecutionTargetTypeInclude, Target: item.Include} + targets[i] = &exec.Target{Type: domain.ExecutionTargetTypeInclude, Target: item.Include} } } return targets, nil diff --git a/internal/query/execution_targets.sql b/internal/query/execution_targets.sql index 32257f4a1f..a6e6dd6caa 100644 --- a/internal/query/execution_targets.sql +++ b/internal/query/execution_targets.sql @@ -1,11 +1,15 @@ -SELECT instance_id, - execution_id, +SELECT et.instance_id, + et.execution_id, JSONB_AGG( JSON_OBJECT( - 'position' : position, - 'include' : include, - 'target' : target_id - ) - ) as targets -FROM projections.executions1_targets -GROUP BY instance_id, execution_id \ No newline at end of file + 'position' : et.position, + 'include' : et.include, + 'target' : et.target_id + ) + ) as targets +FROM projections.executions1_targets AS et + INNER JOIN projections.targets2 AS t + ON et.instance_id = t.instance_id + AND et.target_id IS NOT NULL + AND et.target_id = t.id +GROUP BY et.instance_id, et.execution_id \ No newline at end of file diff --git a/internal/query/execution_test.go b/internal/query/execution_test.go index eaaac1e9ba..64f9a4849f 100644 --- a/internal/query/execution_test.go +++ b/internal/query/execution_test.go @@ -22,9 +22,10 @@ var ( ` COUNT(*) OVER ()` + ` FROM projections.executions1` + ` JOIN (` + - `SELECT instance_id, execution_id, JSONB_AGG( JSON_OBJECT( 'position' : position, 'include' : include, 'target' : target_id ) ) as targets` + - ` FROM projections.executions1_targets` + - ` GROUP BY instance_id, execution_id` + + `SELECT et.instance_id, et.execution_id, JSONB_AGG( JSON_OBJECT( 'position' : et.position, 'include' : et.include, 'target' : et.target_id ) ) as targets` + + ` FROM projections.executions1_targets AS et` + + ` INNER JOIN projections.targets2 AS t ON et.instance_id = t.instance_id AND et.target_id IS NOT NULL AND et.target_id = t.id` + + ` GROUP BY et.instance_id, et.execution_id` + `)` + ` AS execution_targets` + ` ON execution_targets.instance_id = projections.executions1.instance_id` + @@ -45,9 +46,10 @@ var ( ` execution_targets.targets` + ` FROM projections.executions1` + ` JOIN (` + - `SELECT instance_id, execution_id, JSONB_AGG( JSON_OBJECT( 'position' : position, 'include' : include, 'target' : target_id ) ) as targets` + - ` FROM projections.executions1_targets` + - ` GROUP BY instance_id, execution_id` + + `SELECT et.instance_id, et.execution_id, JSONB_AGG( JSON_OBJECT( 'position' : et.position, 'include' : et.include, 'target' : et.target_id ) ) as targets` + + ` FROM projections.executions1_targets AS et` + + ` INNER JOIN projections.targets2 AS t ON et.instance_id = t.instance_id AND et.target_id IS NOT NULL AND et.target_id = t.id` + + ` GROUP BY et.instance_id, et.execution_id` + `)` + ` AS execution_targets` + ` ON execution_targets.instance_id = projections.executions1.instance_id` + @@ -179,6 +181,63 @@ func Test_ExecutionPrepares(t *testing.T) { }, }, }, + { + name: "prepareExecutionsQuery multiple result, removed target, position missing", + prepare: prepareExecutionsQuery, + want: want{ + sqlExpectations: mockQueries( + regexp.QuoteMeta(prepareExecutionsStmt), + prepareExecutionsCols, + [][]driver.Value{ + { + "ro", + "id-1", + testNow, + testNow, + []byte(`[{"position" : 1, "target" : "target"}, {"position" : 3, "include" : "include"}]`), + }, + { + "ro", + "id-2", + testNow, + testNow, + []byte(`[{"position" : 2, "target" : "target"}, {"position" : 1, "include" : "include"}]`), + }, + }, + ), + }, + object: &Executions{ + SearchResponse: SearchResponse{ + Count: 2, + }, + Executions: []*Execution{ + { + ObjectDetails: domain.ObjectDetails{ + ID: "id-1", + EventDate: testNow, + CreationDate: testNow, + ResourceOwner: "ro", + }, + Targets: []*exec.Target{ + {Type: domain.ExecutionTargetTypeTarget, Target: "target"}, + {Type: domain.ExecutionTargetTypeInclude, Target: "include"}, + }, + }, + { + ObjectDetails: domain.ObjectDetails{ + ID: "id-2", + EventDate: testNow, + CreationDate: testNow, + ResourceOwner: "ro", + }, + Targets: []*exec.Target{ + {Type: domain.ExecutionTargetTypeInclude, Target: "include"}, + {Type: domain.ExecutionTargetTypeTarget, Target: "target"}, + }, + }, + }, + }, + }, { name: "prepareExecutionsQuery sql err", prepare: prepareExecutionsQuery, diff --git a/internal/query/projection/execution.go b/internal/query/projection/execution.go index 9001fcd3ba..1bd7f2e7f5 100644 --- a/internal/query/projection/execution.go +++ b/internal/query/projection/execution.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore/handler/v2" exec "github.com/zitadel/zitadel/internal/repository/execution" "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/target" ) const ( @@ -78,6 +79,15 @@ func (p *executionProjection) Reducers() []handler.AggregateReducer { }, }, }, + { + Aggregate: target.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: target.RemovedEventType, + Reduce: p.reduceTargetRemoved, + }, + }, + }, { Aggregate: instance.AggregateType, EventReducers: []handler.EventReducer{ @@ -152,6 +162,21 @@ func (p *executionProjection) reduceExecutionSet(event eventstore.Event) (*handl return handler.NewMultiStatement(e, stmts...), nil } +func (p *executionProjection) reduceTargetRemoved(event eventstore.Event) (*handler.Statement, error) { + e, err := assertEvent[*target.RemovedEvent](event) + if err != nil { + return nil, err + } + return handler.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(ExecutionTargetInstanceIDCol, e.Aggregate().InstanceID), + handler.NewCond(ExecutionTargetTargetIDCol, e.Aggregate().ID), + }, + handler.WithTableSuffix(ExecutionTargetSuffix), + ), nil +} + func (p *executionProjection) reduceExecutionRemoved(event eventstore.Event) (*handler.Statement, error) { e, err := assertEvent[*exec.RemovedEvent](event) if err != nil { diff --git a/internal/query/projection/execution_test.go b/internal/query/projection/execution_test.go index 27d6e89258..aecae6905a 100644 --- a/internal/query/projection/execution_test.go +++ b/internal/query/projection/execution_test.go @@ -7,6 +7,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore/handler/v2" exec "github.com/zitadel/zitadel/internal/repository/execution" "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/target" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -79,6 +80,35 @@ func TestExecutionProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduceTargetRemoved", + args: args{ + event: getEvent( + testEvent( + target.RemovedEventType, + target.AggregateType, + []byte(`{}`), + ), + eventstore.GenericEventMapper[target.RemovedEvent], + ), + }, + reduce: (&executionProjection{}).reduceTargetRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("target"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.executions1_targets WHERE (instance_id = $1) AND (target_id = $2)", + expectedArgs: []interface{}{ + "instance-id", + "agg-id", + }, + }, + }, + }, + }, + }, { name: "reduceExecutionRemoved", args: args{ From 02acc932425c9b3519b8ad6c1dc334ad134319c5 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 30 Apr 2025 15:20:39 +0200 Subject: [PATCH 034/123] fix: Improve Actions V2 translations (#9826) # Which Problems Are Solved The translation for event was not loaded correctly. ![grafik](https://github.com/user-attachments/assets/3fa8d72f-f55a-44b7-997d-0f0976f66b85) # How the Problems Are Solved Correct translations to have the correct key. # Additional Changes Improved the translation for all events. --- .../actions-two-add-action-condition.component.html | 2 +- console/src/assets/i18n/bg.json | 3 ++- console/src/assets/i18n/cs.json | 3 ++- console/src/assets/i18n/de.json | 3 ++- console/src/assets/i18n/en.json | 3 ++- console/src/assets/i18n/es.json | 3 ++- console/src/assets/i18n/fr.json | 3 ++- console/src/assets/i18n/hu.json | 3 ++- console/src/assets/i18n/id.json | 3 ++- console/src/assets/i18n/it.json | 3 ++- console/src/assets/i18n/ja.json | 3 ++- console/src/assets/i18n/ko.json | 3 ++- console/src/assets/i18n/mk.json | 3 ++- console/src/assets/i18n/nl.json | 3 ++- console/src/assets/i18n/pl.json | 3 ++- console/src/assets/i18n/pt.json | 3 ++- console/src/assets/i18n/ro.json | 3 ++- console/src/assets/i18n/ru.json | 3 ++- console/src/assets/i18n/sv.json | 3 ++- console/src/assets/i18n/zh.json | 3 ++- 20 files changed, 39 insertions(+), 20 deletions(-) diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html index 401e5e521d..f0248f45a2 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-condition/actions-two-add-action-condition.component.html @@ -84,7 +84,7 @@
{{ 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.ALL.TITLE' | translate }} {{ - 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.ALL.DESCRIPTION' | translate + 'ACTIONSTWO.EXECUTION.DIALOG.CONDITION.ALL_EVENTS' | translate }}
diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 30d9c53763..b98204a917 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -536,7 +536,7 @@ "TYPES": { "request": "Заявка", "response": "Отговор", - "events": "Събития", + "event": "Събития", "function": "Функция" }, "DIALOG": { @@ -567,6 +567,7 @@ "TITLE": "Всички", "DESCRIPTION": "Изберете това, ако искате да изпълните действието си при всяка заявка" }, + "ALL_EVENTS": "Изберете това, ако искате действието да се изпълнява при всяко събитие", "SELECT_SERVICE": { "TITLE": "Избор на услуга", "DESCRIPTION": "Изберете услуга на Zitadel за вашето действие." diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index ee43e86822..390c5dcdbd 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Požadavek", "response": "Odpověď", - "events": "Události", + "event": "Události", "function": "Funkce" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Všechny", "DESCRIPTION": "Vyberte tuto možnost, pokud chcete spustit akci pro každý požadavek" }, + "ALL_EVENTS": "Vyberte toto, pokud chcete spustit akci při každé události", "SELECT_SERVICE": { "TITLE": "Vybrat službu", "DESCRIPTION": "Vyberte službu Zitadel pro svou akci." diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 3993674992..e73c883bd2 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Anfrage", "response": "Antwort", - "events": "Ereignisse", + "event": "Ereignisse", "function": "Funktion" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Alle", "DESCRIPTION": "Wählen Sie dies aus, wenn Sie Ihre Aktion bei jeder Anfrage ausführen möchten" }, + "ALL_EVENTS": "Wähle dies aus, wenn du deine Aktion bei jedem Ereignis ausführen möchtest", "SELECT_SERVICE": { "TITLE": "Dienst auswählen", "DESCRIPTION": "Wählen Sie einen Zitadel-Dienst für Ihre Aktion aus." diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index fd81bfd353..5e2cc3f4c9 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Request", "response": "Response", - "events": "Events", + "event": "Events", "function": "Function" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "All", "DESCRIPTION": "Select this if you want to run your action on every request" }, + "ALL_EVENTS": "Select this if you want to run your action on every event", "SELECT_SERVICE": { "TITLE": "Select Service", "DESCRIPTION": "Choose a Zitadel Service for you action." diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index aec024eacb..198bb3ca8b 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Solicitud", "response": "Respuesta", - "events": "Eventos", + "event": "Eventos", "function": "Función" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Todas", "DESCRIPTION": "Selecciona esto si quieres ejecutar tu acción en cada solicitud" }, + "ALL_EVENTS": "Selecciona esto si quieres ejecutar tu acción en cada evento", "SELECT_SERVICE": { "TITLE": "Seleccionar servicio", "DESCRIPTION": "Elige un servicio de Zitadel para tu acción." diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 05e34ad846..0d66c4193e 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Requête", "response": "Réponse", - "events": "Événements", + "event": "Événements", "function": "Fonction" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Tous", "DESCRIPTION": "Sélectionnez ceci si vous souhaitez exécuter votre action sur chaque requête" }, + "ALL_EVENTS": "Sélectionnez ceci si vous souhaitez exécuter votre action à chaque événement", "SELECT_SERVICE": { "TITLE": "Sélectionner un service", "DESCRIPTION": "Choisissez un service Zitadel pour votre action." diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index d46bc96153..96d1fe16df 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Kérés", "response": "Válasz", - "events": "Események", + "event": "Események", "function": "Függvény" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Összes", "DESCRIPTION": "Válassza ezt, ha minden kérésnél futtatni szeretné a műveletet" }, + "ALL_EVENTS": "Válaszd ezt, ha minden eseménynél futtatni szeretnéd a műveletet", "SELECT_SERVICE": { "TITLE": "Szolgáltatás kiválasztása", "DESCRIPTION": "Válasszon egy Zitadel szolgáltatást a művelethez." diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index f8831a224d..ca788a9467 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -504,7 +504,7 @@ "TYPES": { "request": "Permintaan", "response": "Respons", - "events": "Peristiwa", + "event": "Peristiwa", "function": "Fungsi" }, "DIALOG": { @@ -535,6 +535,7 @@ "TITLE": "Semua", "DESCRIPTION": "Pilih ini jika Anda ingin menjalankan tindakan Anda pada setiap permintaan" }, + "ALL_EVENTS": "Pilih ini jika Anda ingin menjalankan aksi Anda pada setiap peristiwa", "SELECT_SERVICE": { "TITLE": "Pilih Layanan", "DESCRIPTION": "Pilih Layanan Zitadel untuk tindakan Anda." diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 5dad683bca..60266bdac5 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -536,7 +536,7 @@ "TYPES": { "request": "Richiesta", "response": "Risposta", - "events": "Eventi", + "event": "Eventi", "function": "Funzione" }, "DIALOG": { @@ -567,6 +567,7 @@ "TITLE": "Tutte", "DESCRIPTION": "Seleziona questa opzione se vuoi eseguire la tua azione su ogni richiesta" }, + "ALL_EVENTS": "Seleziona questo se vuoi eseguire la tua azione a ogni evento", "SELECT_SERVICE": { "TITLE": "Seleziona servizio", "DESCRIPTION": "Scegli un servizio Zitadel per la tua azione." diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index f09dfeb564..288d491ce7 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -537,7 +537,7 @@ "TYPES": { "request": "リクエスト", "response": "レスポンス", - "events": "イベント", + "event": "イベント", "function": "関数" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "すべて", "DESCRIPTION": "すべてのリクエストでアクションを実行する場合は、これを選択します" }, + "ALL_EVENTS": "すべてのイベントでアクションを実行する場合はこれを選択してください", "SELECT_SERVICE": { "TITLE": "サービスを選択", "DESCRIPTION": "アクションのZitadelサービスを選択します。" diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index 304f52e127..437c43a3a1 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -537,7 +537,7 @@ "TYPES": { "request": "요청", "response": "응답", - "events": "이벤트", + "event": "이벤트", "function": "함수" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "모두", "DESCRIPTION": "모든 요청에서 작업을 실행하려면 이것을 선택하십시오." }, + "ALL_EVENTS": "모든 이벤트에서 작업을 실행하려면 이 항목을 선택하세요", "SELECT_SERVICE": { "TITLE": "서비스 선택", "DESCRIPTION": "작업에 대한 Zitadel 서비스를 선택하십시오." diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 1ab4dce534..2e62723939 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Барање", "response": "Одговор", - "events": "Настани", + "event": "Настани", "function": "Функција" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Сите", "DESCRIPTION": "Изберете го ова ако сакате да ја извршите вашата акција на секое барање" }, + "ALL_EVENTS": "Изберете го ова ако сакате вашата акција да се извршува на секој настан", "SELECT_SERVICE": { "TITLE": "Изберете услуга", "DESCRIPTION": "Изберете Zitadel услуга за вашата акција." diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index a08f62ed20..7e549f64ba 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Verzoek", "response": "Reactie", - "events": "Gebeurtenissen", + "event": "Gebeurtenissen", "function": "Functie" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Alle", "DESCRIPTION": "Selecteer dit als u uw actie bij elk verzoek wilt uitvoeren" }, + "ALL_EVENTS": "Selecteer dit als je je actie bij elk evenement wilt uitvoeren", "SELECT_SERVICE": { "TITLE": "Service selecteren", "DESCRIPTION": "Kies een Zitadel-service voor uw actie." diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 3902378af6..2f18c343f7 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -536,7 +536,7 @@ "TYPES": { "request": "Żądanie", "response": "Odpowiedź", - "events": "Zdarzenia", + "event": "Zdarzenia", "function": "Funkcja" }, "DIALOG": { @@ -567,6 +567,7 @@ "TITLE": "Wszystkie", "DESCRIPTION": "Wybierz tę opcję, jeśli chcesz uruchomić akcję dla każdego żądania" }, + "ALL_EVENTS": "Wybierz to, jeśli chcesz uruchamiać swoją akcję przy każdym zdarzeniu", "SELECT_SERVICE": { "TITLE": "Wybierz usługę", "DESCRIPTION": "Wybierz usługę Zitadel dla swojej akcji." diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 016785f2c8..08181f6ead 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Solicitação", "response": "Resposta", - "events": "Eventos", + "event": "Eventos", "function": "Função" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Todas", "DESCRIPTION": "Selecione isso se você quiser executar sua ação em cada solicitação" }, + "ALL_EVENTS": "Selecione isto se quiser executar sua ação em cada evento", "SELECT_SERVICE": { "TITLE": "Selecionar Serviço", "DESCRIPTION": "Escolha um Serviço Zitadel para sua ação." diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index 4ad511c466..b07897f316 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Cerere", "response": "Răspuns", - "events": "Evenimente", + "event": "Evenimente", "function": "Funcție" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Toate", "DESCRIPTION": "Selectați aceasta dacă doriți să rulați acțiunea la fiecare cerere" }, + "ALL_EVENTS": "Selectează aceasta dacă vrei să rulezi acțiunea ta la fiecare eveniment", "SELECT_SERVICE": { "TITLE": "Selectați Serviciul", "DESCRIPTION": "Alegeți un Serviciu Zitadel pentru acțiunea dvs." diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 43bc266be0..c6ef31499e 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Запрос", "response": "Ответ", - "events": "События", + "event": "События", "function": "Функция" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Все", "DESCRIPTION": "Выберите это, если вы хотите запустить свое действие при каждом запросе" }, + "ALL_EVENTS": "Выберите это, если хотите выполнять действие при каждом событии", "SELECT_SERVICE": { "TITLE": "Выбрать службу", "DESCRIPTION": "Выберите службу Zitadel для вашего действия." diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 00b7854603..c356e635e0 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -537,7 +537,7 @@ "TYPES": { "request": "Förfrågan", "response": "Svar", - "events": "Händelser", + "event": "Händelser", "function": "Funktion" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "Alla", "DESCRIPTION": "Välj detta om du vill köra din åtgärd på varje förfrågan" }, + "ALL_EVENTS": "Välj detta om du vill köra din åtgärd vid varje händelse", "SELECT_SERVICE": { "TITLE": "Välj tjänst", "DESCRIPTION": "Välj en Zitadel-tjänst för din åtgärd." diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 496b3d528e..8be3316b0b 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -537,7 +537,7 @@ "TYPES": { "request": "请求", "response": "响应", - "events": "事件", + "event": "事件", "function": "函数" }, "DIALOG": { @@ -568,6 +568,7 @@ "TITLE": "全部", "DESCRIPTION": "如果您希望在每个请求上运行您的操作,请选择此项" }, + "ALL_EVENTS": "如果您想在每个事件上运行操作,请选择此项", "SELECT_SERVICE": { "TITLE": "选择服务", "DESCRIPTION": "为您的操作选择一个 Zitadel 服务。" From 74ace1aec31eb6085c1b871c7465524f5f9d13cf Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 1 May 2025 07:41:57 +0200 Subject: [PATCH 035/123] fix(actions): default sorting column to creation date (#9795) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved The sorting column of action targets and executions defaults to the ID column instead of the creation date column. This is only relevant, if the sorting column is explicitly passed as unspecified. If the sorting column is not passed, it correctly defaults to the creation date. ```bash # ❌ Sorts by ID grpcurl -plaintext -H "Authorization: Bearer ${ZITADEL_ACCESS_TOKEN}" -d '{"sortingColumn": "TARGET_FIELD_NAME_UNSPECIFIED"}' localhost:8080 zitadel.action.v2beta.ActionService.ListTargets # ❌ Sorts by ID grpcurl -plaintext -H "Authorization: Bearer ${ZITADEL_ACCESS_TOKEN}" -d '{"sortingColumn": 0}' localhost:8080 zitadel.action.v2beta.ActionService.ListTargets # ✅ Sorts by creation date grpcurl -plaintext -H "Authorization: Bearer ${ZITADEL_ACCESS_TOKEN}" localhost:8080 zitadel.action.v2beta.ActionService.ListTargets ``` # How the Problems Are Solved `action.TargetFieldName_TARGET_FIELD_NAME_UNSPECIFIED` maps to the sorting column `query.TargetColumnCreationDate`. # Additional Context As IDs are also generated in ascending, like creation dates, the the bug probably only causes unexpected behavior for cases, where the ID is specified during target or execution creation. This is currently not supported, so this bug probably has no impact at all. It doesn't need to be backported. Found during implementation of #9763 Co-authored-by: Livio Spring --- internal/api/grpc/action/v2beta/query.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/grpc/action/v2beta/query.go b/internal/api/grpc/action/v2beta/query.go index 66bafa4e7d..1dbe80a8f7 100644 --- a/internal/api/grpc/action/v2beta/query.go +++ b/internal/api/grpc/action/v2beta/query.go @@ -164,7 +164,7 @@ func targetFieldNameToSortingColumn(field *action.TargetFieldName) query.Column } switch *field { case action.TargetFieldName_TARGET_FIELD_NAME_UNSPECIFIED: - return query.TargetColumnID + return query.TargetColumnCreationDate case action.TargetFieldName_TARGET_FIELD_NAME_ID: return query.TargetColumnID case action.TargetFieldName_TARGET_FIELD_NAME_CREATED_DATE: @@ -193,7 +193,7 @@ func executionFieldNameToSortingColumn(field *action.ExecutionFieldName) query.C } switch *field { case action.ExecutionFieldName_EXECUTION_FIELD_NAME_UNSPECIFIED: - return query.ExecutionColumnID + return query.ExecutionColumnCreationDate case action.ExecutionFieldName_EXECUTION_FIELD_NAME_ID: return query.ExecutionColumnID case action.ExecutionFieldName_EXECUTION_FIELD_NAME_CREATED_DATE: From bb56b362a755b5e07dfd364db59e1c02cd21690e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 2 May 2025 13:40:22 +0200 Subject: [PATCH 036/123] perf(eventstore): add instance position index (#9837) # Which Problems Are Solved Some projection queries took a long time to run. It seems that 1 or more queries couldn't make proper use of the `es_projection` index. This might be because of a specific complexity aggregate_type and event_type arguments, making the index unfeasible for postgres. # How the Problems Are Solved Following the index recommendation, add and index that covers just instance_id and position. # Additional Changes - none # Additional Context - Related to https://github.com/zitadel/zitadel/issues/9832 --- cmd/setup/54.go | 27 +++++++++++++++++++++++++++ cmd/setup/54.sql | 1 + 2 files changed, 28 insertions(+) create mode 100644 cmd/setup/54.go create mode 100644 cmd/setup/54.sql diff --git a/cmd/setup/54.go b/cmd/setup/54.go new file mode 100644 index 0000000000..3dd2f60abe --- /dev/null +++ b/cmd/setup/54.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 54.sql + instancePositionIndex string +) + +type InstancePositionIndex struct { + dbClient *database.DB +} + +func (mig *InstancePositionIndex) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, instancePositionIndex) + return err +} + +func (mig *InstancePositionIndex) String() string { + return "54_instance_position_index" +} diff --git a/cmd/setup/54.sql b/cmd/setup/54.sql new file mode 100644 index 0000000000..1dca8c7575 --- /dev/null +++ b/cmd/setup/54.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS es_instance_position ON eventstore.events2 (instance_id, position); From b1e60e7398d677f08b06fd7715227f70b7ca1162 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 2 May 2025 13:44:24 +0200 Subject: [PATCH 037/123] Merge commit from fork * fix: prevent intent token reuse and add expiry * fix duplicate * fix expiration --- cmd/defaults.yaml | 3 + .../v2/integration_test/session_test.go | 80 +++++++++++- .../v2beta/integration_test/session_test.go | 80 +++++++++++- .../user/v2/integration_test/user_test.go | 52 ++++++-- internal/api/grpc/user/v2/intent.go | 17 ++- .../user/v2beta/integration_test/user_test.go | 52 ++++++-- internal/api/grpc/user/v2beta/user.go | 12 +- internal/api/idp/idp.go | 2 +- internal/api/idp/idp_test.go | 38 +++--- internal/command/command.go | 2 + internal/command/idp_intent.go | 20 ++- internal/command/idp_intent_model.go | 29 ++++- internal/command/idp_intent_test.go | 56 ++++++--- internal/command/session.go | 51 ++++---- internal/command/session_test.go | 114 +++++++++++++++++- .../config/systemdefaults/system_defaults.go | 19 +-- internal/domain/idp.go | 1 + internal/idp/providers/apple/session.go | 2 + internal/idp/providers/azuread/session.go | 10 ++ internal/idp/providers/jwt/session.go | 7 ++ internal/idp/providers/ldap/session.go | 4 + internal/idp/providers/oauth/session.go | 8 ++ internal/idp/providers/oidc/session.go | 8 ++ internal/idp/providers/saml/session.go | 8 ++ internal/idp/session.go | 2 + internal/integration/client.go | 17 +++ internal/integration/sink/server.go | 42 +++++-- internal/repository/idpintent/eventstore.go | 1 + internal/repository/idpintent/intent.go | 40 ++++++ internal/static/i18n/bg.yaml | 1 + internal/static/i18n/cs.yaml | 1 + internal/static/i18n/de.yaml | 1 + internal/static/i18n/en.yaml | 1 + internal/static/i18n/es.yaml | 1 + internal/static/i18n/fr.yaml | 1 + internal/static/i18n/hu.yaml | 1 + internal/static/i18n/id.yaml | 1 + internal/static/i18n/it.yaml | 1 + internal/static/i18n/ja.yaml | 1 + internal/static/i18n/ko.yaml | 1 + internal/static/i18n/mk.yaml | 1 + internal/static/i18n/nl.yaml | 1 + internal/static/i18n/pl.yaml | 1 + internal/static/i18n/pt.yaml | 1 + internal/static/i18n/ro.yaml | 1 + internal/static/i18n/ru.yaml | 1 + internal/static/i18n/sv.yaml | 1 + internal/static/i18n/zh.yaml | 1 + 48 files changed, 673 insertions(+), 123 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 6ab01ab35b..0d71b4d817 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -735,6 +735,9 @@ SystemDefaults: DefaultQueryLimit: 100 # ZITADEL_SYSTEMDEFAULTS_DEFAULTQUERYLIMIT # MaxQueryLimit limits the number of items that can be queried in a single v3 API search request with explicitly passing a limit. MaxQueryLimit: 1000 # ZITADEL_SYSTEMDEFAULTS_MAXQUERYLIMIT + # The maximum duration of the IDP intent lifetime after which the IDP intent expires and can not be retrieved or used anymore. + # Note that this time is measured only after the IdP intent was successful and not after the IDP intent was created. + MaxIdPIntentLifetime: 1h # ZITADEL_SYSTEMDEFAULTS_MAXIDPINTENTLIFETIME Actions: HTTP: diff --git a/internal/api/grpc/session/v2/integration_test/session_test.go b/internal/api/grpc/session/v2/integration_test/session_test.go index b9a060c749..0982a56121 100644 --- a/internal/api/grpc/session/v2/integration_test/session_test.go +++ b/internal/api/grpc/session/v2/integration_test/session_test.go @@ -354,7 +354,7 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { require.NoError(t, err) verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) - intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Hour)) require.NoError(t, err) updateResp, err := Client.SetSession(LoginCTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), @@ -372,7 +372,7 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { func TestServer_CreateSession_successfulIntent_instant(t *testing.T) { idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() - intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Hour)) require.NoError(t, err) createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ @@ -396,7 +396,7 @@ func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { // successful intent without known / linked user idpUserID := "id" - intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, idpUserID, "") + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, idpUserID, "", time.Now().Add(time.Hour)) // link the user (with info from intent) Instance.CreateUserIDPlink(CTX, User.GetUserId(), idpUserID, idpID, User.GetUserId()) @@ -447,6 +447,80 @@ func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { require.Error(t, err) } +func TestServer_CreateSession_reuseIntent(t *testing.T) { + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() + createResp, err := Client.CreateSession(LoginCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Hour)) + require.NoError(t, err) + updateResp, err := Client.SetSession(LoginCTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor) + + // the reuse of the intent token is not allowed, not even on the same session + session2, err := Client.SetSession(LoginCTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.Error(t, err) + _ = session2 +} + +func TestServer_CreateSession_expiredIntent(t *testing.T) { + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() + createResp, err := Client.CreateSession(LoginCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Second)) + require.NoError(t, err) + + // wait for the intent to expire + time.Sleep(2 * time.Second) + + _, err = Client.SetSession(LoginCTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.Error(t, err) +} + func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) { resp, err := Instance.Client.UserV2.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ UserId: userID, diff --git a/internal/api/grpc/session/v2beta/integration_test/session_test.go b/internal/api/grpc/session/v2beta/integration_test/session_test.go index d0fc1179ef..4c189e0f80 100644 --- a/internal/api/grpc/session/v2beta/integration_test/session_test.go +++ b/internal/api/grpc/session/v2beta/integration_test/session_test.go @@ -354,7 +354,7 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { require.NoError(t, err) verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) - intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Hour)) require.NoError(t, err) updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ SessionId: createResp.GetSessionId(), @@ -372,7 +372,7 @@ func TestServer_CreateSession_successfulIntent(t *testing.T) { func TestServer_CreateSession_successfulIntent_instant(t *testing.T) { idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() - intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Hour)) require.NoError(t, err) createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ Checks: &session.Checks{ @@ -396,7 +396,7 @@ func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { // successful intent without known / linked user idpUserID := "id" - intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId()) + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Hour)) require.NoError(t, err) // link the user (with info from intent) @@ -448,6 +448,80 @@ func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { require.Error(t, err) } +func TestServer_CreateSession_reuseIntent(t *testing.T) { + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() + createResp, err := Client.CreateSession(IAMOwnerCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Hour)) + require.NoError(t, err) + updateResp, err := Client.SetSession(IAMOwnerCTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId(), wantUserFactor, wantIntentFactor) + + // the reuse of the intent token is not allowed, not even on the same session + session2, err := Client.SetSession(IAMOwnerCTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.Error(t, err) + _ = session2 +} + +func TestServer_CreateSession_expiredIntent(t *testing.T) { + idpID := Instance.AddGenericOAuthProvider(IAMOwnerCTX, gofakeit.AppName()).GetId() + createResp, err := Client.CreateSession(IAMOwnerCTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, nil, 0, User.GetUserId()) + + intentID, token, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), idpID, "id", User.GetUserId(), time.Now().Add(time.Second)) + require.NoError(t, err) + + // wait for the intent to expire + time.Sleep(2 * time.Second) + + _, err = Client.SetSession(IAMOwnerCTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + Checks: &session.Checks{ + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: token, + }, + }, + }) + require.Error(t, err) +} + func registerTOTP(ctx context.Context, t *testing.T, userID string) (secret string) { resp, err := Instance.Client.UserV2.RegisterTOTP(ctx, &user.RegisterTOTPRequest{ UserId: userID, diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index bf396fd25d..70e670bacc 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -2121,22 +2121,36 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { authURL, err := url.Parse(Instance.CreateIntent(CTX, oauthIdpID).GetAuthUrl()) require.NoError(t, err) intentID := authURL.Query().Get("state") + expiry := time.Now().Add(1 * time.Hour) + expiryFormatted := expiry.Round(time.Millisecond).UTC().Format("2006-01-02T15:04:05.999Z07:00") - successfulID, token, changeDate, sequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "") + intentUser := Instance.CreateHumanUser(IamCTX) + _, err = Instance.CreateUserIDPlink(IamCTX, intentUser.GetUserId(), "idpUserID", oauthIdpID, "username") require.NoError(t, err) - successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "user") + + successfulID, token, changeDate, sequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "", expiry) require.NoError(t, err) - oidcSuccessful, oidcToken, oidcChangeDate, oidcSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "") + successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "user", expiry) require.NoError(t, err) - oidcSuccessfulWithUserID, oidcWithUserIDToken, oidcWithUserIDChangeDate, oidcWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "user") + successfulExpiredID, expiredToken, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "user", time.Now().Add(time.Second)) + require.NoError(t, err) + // make sure the intent is expired + time.Sleep(2 * time.Second) + successfulConsumedID, consumedToken, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "idpUserID", intentUser.GetUserId(), expiry) + require.NoError(t, err) + // make sure the intent is consumed + Instance.CreateIntentSession(t, IamCTX, intentUser.GetUserId(), successfulConsumedID, consumedToken) + oidcSuccessful, oidcToken, oidcChangeDate, oidcSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "", expiry) + require.NoError(t, err) + oidcSuccessfulWithUserID, oidcWithUserIDToken, oidcWithUserIDChangeDate, oidcWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "user", expiry) require.NoError(t, err) ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "") require.NoError(t, err) ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "user") require.NoError(t, err) - samlSuccessfulID, samlToken, samlChangeDate, samlSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "") + samlSuccessfulID, samlToken, samlChangeDate, samlSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "", expiry) require.NoError(t, err) - samlSuccessfulWithUserID, samlWithUserToken, samlWithUserChangeDate, samlWithUserSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "user") + samlSuccessfulWithUserID, samlWithUserToken, samlWithUserChangeDate, samlWithUserSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "user", expiry) require.NoError(t, err) type args struct { ctx context.Context @@ -2260,6 +2274,28 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }, wantErr: false, }, + { + name: "retrieve successful expired intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: successfulExpiredID, + IdpIntentToken: expiredToken, + }, + }, + wantErr: true, + }, + { + name: "retrieve successful consumed intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: successfulConsumedID, + IdpIntentToken: consumedToken, + }, + }, + wantErr: true, + }, { name: "retrieve successful oidc intent", args: args{ @@ -2469,7 +2505,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdpInformation: &user.IDPInformation{ Access: &user.IDPInformation_Saml{ Saml: &user.IDPSAMLAccessInformation{ - Assertion: []byte(""), + Assertion: []byte(fmt.Sprintf(``, expiryFormatted)), }, }, IdpId: samlIdpID, @@ -2518,7 +2554,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdpInformation: &user.IDPInformation{ Access: &user.IDPInformation_Saml{ Saml: &user.IDPSAMLAccessInformation{ - Assertion: []byte(""), + Assertion: []byte(fmt.Sprintf(``, expiryFormatted)), }, }, IdpId: samlIdpID, diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index 06966edb35..8043a9bdae 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "time" oidc_pkg "github.com/zitadel/oidc/v3/pkg/oidc" "google.golang.org/protobuf/types/known/structpb" @@ -71,14 +72,14 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti if err != nil { return nil, err } - externalUser, userID, attributes, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword()) + externalUser, userID, session, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword()) if err != nil { if err := s.command.FailIDPIntent(ctx, intentWriteModel, err.Error()); err != nil { return nil, err } return nil, err } - token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, attributes) + token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, session) if err != nil { return nil, err } @@ -116,7 +117,7 @@ func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUse return "", nil } -func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, map[string][]string, error) { +func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, *ldap.Session, error) { provider, err := s.command.GetProvider(ctx, idpID, "", "") if err != nil { return nil, "", nil, err @@ -137,12 +138,7 @@ func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string if err != nil { return nil, "", nil, err } - - attributes := make(map[string][]string, 0) - for _, item := range session.Entry.Attributes { - attributes[item.Name] = item.Values - } - return externalUser, userID, attributes, nil + return externalUser, userID, session, nil } func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { @@ -156,6 +152,9 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R if intent.State != domain.IDPIntentStateSucceeded { return nil, zerrors.ThrowPreconditionFailed(nil, "IDP-nme4gszsvx", "Errors.Intent.NotSucceeded") } + if time.Now().After(intent.ExpiresAt()) { + return nil, zerrors.ThrowPreconditionFailed(nil, "IDP-SAf42", "Errors.Intent.Expired") + } idpIntent, err := idpIntentToIDPIntentPb(intent, s.idpAlg) if err != nil { return nil, err diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index a81de58761..a5a1309d1a 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -2153,22 +2153,36 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { authURL, err := url.Parse(Instance.CreateIntent(CTX, oauthIdpID).GetAuthUrl()) require.NoError(t, err) intentID := authURL.Query().Get("state") + expiry := time.Now().Add(1 * time.Hour) + expiryFormatted := expiry.Round(time.Millisecond).UTC().Format("2006-01-02T15:04:05.999Z07:00") - successfulID, token, changeDate, sequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "") + intentUser := Instance.CreateHumanUser(IamCTX) + _, err = Instance.CreateUserIDPlink(IamCTX, intentUser.GetUserId(), "idpUserID", oauthIdpID, "username") require.NoError(t, err) - successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "user") + + successfulID, token, changeDate, sequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "", expiry) require.NoError(t, err) - oidcSuccessful, oidcToken, oidcChangeDate, oidcSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "") + successfulWithUserID, withUsertoken, withUserchangeDate, withUsersequence, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "user", expiry) require.NoError(t, err) - oidcSuccessfulWithUserID, oidcWithUserIDToken, oidcWithUserIDChangeDate, oidcWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "user") + successfulExpiredID, expiredToken, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "id", "user", time.Now().Add(time.Second)) + require.NoError(t, err) + // make sure the intent is expired + time.Sleep(2 * time.Second) + successfulConsumedID, consumedToken, _, _, err := sink.SuccessfulOAuthIntent(Instance.ID(), oauthIdpID, "idpUserID", intentUser.GetUserId(), expiry) + require.NoError(t, err) + // make sure the intent is consumed + Instance.CreateIntentSession(t, IamCTX, intentUser.GetUserId(), successfulConsumedID, consumedToken) + oidcSuccessful, oidcToken, oidcChangeDate, oidcSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "", expiry) + require.NoError(t, err) + oidcSuccessfulWithUserID, oidcWithUserIDToken, oidcWithUserIDChangeDate, oidcWithUserIDSequence, err := sink.SuccessfulOIDCIntent(Instance.ID(), oidcIdpID, "id", "user", expiry) require.NoError(t, err) ldapSuccessfulID, ldapToken, ldapChangeDate, ldapSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "") require.NoError(t, err) ldapSuccessfulWithUserID, ldapWithUserToken, ldapWithUserChangeDate, ldapWithUserSequence, err := sink.SuccessfulLDAPIntent(Instance.ID(), ldapIdpID, "id", "user") require.NoError(t, err) - samlSuccessfulID, samlToken, samlChangeDate, samlSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "") + samlSuccessfulID, samlToken, samlChangeDate, samlSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "", expiry) require.NoError(t, err) - samlSuccessfulWithUserID, samlWithUserToken, samlWithUserChangeDate, samlWithUserSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "user") + samlSuccessfulWithUserID, samlWithUserToken, samlWithUserChangeDate, samlWithUserSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "user", expiry) require.NoError(t, err) type args struct { ctx context.Context @@ -2281,6 +2295,28 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }, wantErr: false, }, + { + name: "retrieve successful expired intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: successfulExpiredID, + IdpIntentToken: expiredToken, + }, + }, + wantErr: true, + }, + { + name: "retrieve successful consumed intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: successfulConsumedID, + IdpIntentToken: consumedToken, + }, + }, + wantErr: true, + }, { name: "retrieve successful oidc intent", args: args{ @@ -2466,7 +2502,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdpInformation: &user.IDPInformation{ Access: &user.IDPInformation_Saml{ Saml: &user.IDPSAMLAccessInformation{ - Assertion: []byte(""), + Assertion: []byte(fmt.Sprintf(``, expiryFormatted)), }, }, IdpId: samlIdpID, @@ -2504,7 +2540,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { IdpInformation: &user.IDPInformation{ Access: &user.IDPInformation_Saml{ Saml: &user.IDPSAMLAccessInformation{ - Assertion: []byte(""), + Assertion: []byte(fmt.Sprintf(``, expiryFormatted)), }, }, IdpId: samlIdpID, diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go index cf6dfa6304..93afbde0aa 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "time" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" @@ -399,14 +400,14 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti if err != nil { return nil, err } - externalUser, userID, attributes, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword()) + externalUser, userID, session, err := s.ldapLogin(ctx, intentWriteModel.IDPID, ldapCredentials.GetUsername(), ldapCredentials.GetPassword()) if err != nil { if err := s.command.FailIDPIntent(ctx, intentWriteModel, err.Error()); err != nil { return nil, err } return nil, err } - token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, attributes) + token, err := s.command.SucceedLDAPIDPIntent(ctx, intentWriteModel, externalUser, userID, session) if err != nil { return nil, err } @@ -444,7 +445,7 @@ func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUse return "", nil } -func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, map[string][]string, error) { +func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string) (idp.User, string, *ldap.Session, error) { provider, err := s.command.GetProvider(ctx, idpID, "", "") if err != nil { return nil, "", nil, err @@ -470,7 +471,7 @@ func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string for _, item := range session.Entry.Attributes { attributes[item.Name] = item.Values } - return externalUser, userID, attributes, nil + return externalUser, userID, session, nil } func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { @@ -484,6 +485,9 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R if intent.State != domain.IDPIntentStateSucceeded { return nil, zerrors.ThrowPreconditionFailed(nil, "IDP-nme4gszsvx", "Errors.Intent.NotSucceeded") } + if time.Now().After(intent.ExpiresAt()) { + return nil, zerrors.ThrowPreconditionFailed(nil, "IDP-Afb2s", "Errors.Intent.Expired") + } return idpIntentToIDPIntentPb(intent, s.idpAlg) } diff --git a/internal/api/idp/idp.go b/internal/api/idp/idp.go index c3e9586a59..ebf904a395 100644 --- a/internal/api/idp/idp.go +++ b/internal/api/idp/idp.go @@ -287,7 +287,7 @@ func (h *Handler) handleACS(w http.ResponseWriter, r *http.Request) { userID, err := h.checkExternalUser(ctx, intent.IDPID, idpUser.GetID()) logging.WithFields("intent", intent.AggregateID).OnError(err).Error("could not check if idp user already exists") - token, err := h.commands.SucceedSAMLIDPIntent(ctx, intent, idpUser, userID, session.Assertion) + token, err := h.commands.SucceedSAMLIDPIntent(ctx, intent, idpUser, userID, session) if err != nil { redirectToFailureURLErr(w, r, intent, zerrors.ThrowInternal(err, "IDP-JdD3g", "Errors.Intent.TokenCreationFailed")) return diff --git a/internal/api/idp/idp_test.go b/internal/api/idp/idp_test.go index 6804a035af..2f64f598a9 100644 --- a/internal/api/idp/idp_test.go +++ b/internal/api/idp/idp_test.go @@ -4,6 +4,7 @@ import ( "net/http/httptest" "net/url" "testing" + "time" "github.com/stretchr/testify/assert" @@ -14,11 +15,12 @@ import ( func Test_redirectToSuccessURL(t *testing.T) { type args struct { - id string - userID string - token string - failureURL string - successURL string + id string + userID string + token string + failureURL string + successURL string + maxIdPIntentLifetime time.Duration } type res struct { want string @@ -59,7 +61,7 @@ func Test_redirectToSuccessURL(t *testing.T) { req := httptest.NewRequest("GET", "http://example.com", nil) resp := httptest.NewRecorder() - wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id) + wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id, tt.args.maxIdPIntentLifetime) wm.FailureURL, _ = url.Parse(tt.args.failureURL) wm.SuccessURL, _ = url.Parse(tt.args.successURL) @@ -71,11 +73,12 @@ func Test_redirectToSuccessURL(t *testing.T) { func Test_redirectToFailureURL(t *testing.T) { type args struct { - id string - failureURL string - successURL string - err string - desc string + id string + failureURL string + successURL string + err string + desc string + maxIdPIntentLifetime time.Duration } type res struct { want string @@ -115,7 +118,7 @@ func Test_redirectToFailureURL(t *testing.T) { req := httptest.NewRequest("GET", "http://example.com", nil) resp := httptest.NewRecorder() - wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id) + wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id, tt.args.maxIdPIntentLifetime) wm.FailureURL, _ = url.Parse(tt.args.failureURL) wm.SuccessURL, _ = url.Parse(tt.args.successURL) @@ -127,10 +130,11 @@ func Test_redirectToFailureURL(t *testing.T) { func Test_redirectToFailureURLErr(t *testing.T) { type args struct { - id string - failureURL string - successURL string - err error + id string + failureURL string + successURL string + err error + maxIdPIntentLifetime time.Duration } type res struct { want string @@ -158,7 +162,7 @@ func Test_redirectToFailureURLErr(t *testing.T) { req := httptest.NewRequest("GET", "http://example.com", nil) resp := httptest.NewRecorder() - wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id) + wm := command.NewIDPIntentWriteModel(tt.args.id, tt.args.id, tt.args.maxIdPIntentLifetime) wm.FailureURL, _ = url.Parse(tt.args.failureURL) wm.SuccessURL, _ = url.Parse(tt.args.successURL) diff --git a/internal/command/command.go b/internal/command/command.go index b0e67ad52e..64b7b53b67 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -81,6 +81,7 @@ type Commands struct { publicKeyLifetime time.Duration certificateLifetime time.Duration defaultSecretGenerators *SecretGenerators + maxIdPIntentLifetime time.Duration samlCertificateAndKeyGenerator func(id string) ([]byte, []byte, error) webKeyGenerator func(keyID string, alg crypto.EncryptionAlgorithm, genConfig crypto.WebKeyConfig) (encryptedPrivate *crypto.CryptoValue, public *jose.JSONWebKey, err error) @@ -152,6 +153,7 @@ func StartCommands( privateKeyLifetime: defaults.KeyConfig.PrivateKeyLifetime, publicKeyLifetime: defaults.KeyConfig.PublicKeyLifetime, certificateLifetime: defaults.KeyConfig.CertificateLifetime, + maxIdPIntentLifetime: defaults.MaxIdPIntentLifetime, idpConfigEncryption: idpConfigEncryption, smtpEncryption: smtpEncryption, smsEncryption: smsEncryption, diff --git a/internal/command/idp_intent.go b/internal/command/idp_intent.go index 3cd9991679..9690117edd 100644 --- a/internal/command/idp_intent.go +++ b/internal/command/idp_intent.go @@ -7,7 +7,6 @@ import ( "encoding/xml" "net/url" - "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" "github.com/zitadel/oidc/v3/pkg/oidc" @@ -19,8 +18,10 @@ import ( "github.com/zitadel/zitadel/internal/idp/providers/apple" "github.com/zitadel/zitadel/internal/idp/providers/azuread" "github.com/zitadel/zitadel/internal/idp/providers/jwt" + "github.com/zitadel/zitadel/internal/idp/providers/ldap" "github.com/zitadel/zitadel/internal/idp/providers/oauth" openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" + "github.com/zitadel/zitadel/internal/idp/providers/saml" "github.com/zitadel/zitadel/internal/repository/idpintent" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -68,7 +69,7 @@ func (c *Commands) CreateIntent(ctx context.Context, intentID, idpID, successURL return nil, nil, err } } - writeModel := NewIDPIntentWriteModel(intentID, resourceOwner) + writeModel := NewIDPIntentWriteModel(intentID, resourceOwner, c.maxIdPIntentLifetime) //nolint: staticcheck cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareCreateIntent(writeModel, idpID, successURL, failureURL, idpArguments)) @@ -180,6 +181,7 @@ func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWr userID, accessToken, idToken, + idpSession.ExpiresAt(), ) err = c.pushAppendAndReduce(ctx, writeModel, cmd) if err != nil { @@ -188,7 +190,7 @@ func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWr return token, nil } -func (c *Commands) SucceedSAMLIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, idpUser idp.User, userID string, assertion *saml.Assertion) (string, error) { +func (c *Commands) SucceedSAMLIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, idpUser idp.User, userID string, session *saml.Session) (string, error) { token, err := c.generateIntentToken(writeModel.AggregateID) if err != nil { return "", err @@ -197,7 +199,7 @@ func (c *Commands) SucceedSAMLIDPIntent(ctx context.Context, writeModel *IDPInte if err != nil { return "", err } - assertionData, err := xml.Marshal(assertion) + assertionData, err := xml.Marshal(session.Assertion) if err != nil { return "", err } @@ -213,6 +215,7 @@ func (c *Commands) SucceedSAMLIDPIntent(ctx context.Context, writeModel *IDPInte idpUser.GetPreferredUsername(), userID, assertionEnc, + session.ExpiresAt(), ) err = c.pushAppendAndReduce(ctx, writeModel, cmd) if err != nil { @@ -237,7 +240,7 @@ func (c *Commands) generateIntentToken(intentID string) (string, error) { return base64.RawURLEncoding.EncodeToString(token), nil } -func (c *Commands) SucceedLDAPIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, idpUser idp.User, userID string, attributes map[string][]string) (string, error) { +func (c *Commands) SucceedLDAPIDPIntent(ctx context.Context, writeModel *IDPIntentWriteModel, idpUser idp.User, userID string, session *ldap.Session) (string, error) { token, err := c.generateIntentToken(writeModel.AggregateID) if err != nil { return "", err @@ -246,6 +249,10 @@ func (c *Commands) SucceedLDAPIDPIntent(ctx context.Context, writeModel *IDPInte if err != nil { return "", err } + attributes := make(map[string][]string, len(session.Entry.Attributes)) + for _, item := range session.Entry.Attributes { + attributes[item.Name] = item.Values + } cmd := idpintent.NewLDAPSucceededEvent( ctx, IDPIntentAggregateFromWriteModel(&writeModel.WriteModel), @@ -254,6 +261,7 @@ func (c *Commands) SucceedLDAPIDPIntent(ctx context.Context, writeModel *IDPInte idpUser.GetPreferredUsername(), userID, attributes, + session.ExpiresAt(), ) err = c.pushAppendAndReduce(ctx, writeModel, cmd) if err != nil { @@ -273,7 +281,7 @@ func (c *Commands) FailIDPIntent(ctx context.Context, writeModel *IDPIntentWrite } func (c *Commands) GetIntentWriteModel(ctx context.Context, id, resourceOwner string) (*IDPIntentWriteModel, error) { - writeModel := NewIDPIntentWriteModel(id, resourceOwner) + writeModel := NewIDPIntentWriteModel(id, resourceOwner, c.maxIdPIntentLifetime) err := c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err diff --git a/internal/command/idp_intent_model.go b/internal/command/idp_intent_model.go index c6bc26ab06..07e0821813 100644 --- a/internal/command/idp_intent_model.go +++ b/internal/command/idp_intent_model.go @@ -2,6 +2,7 @@ package command import ( "net/url" + "time" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" @@ -29,18 +30,29 @@ type IDPIntentWriteModel struct { RequestID string Assertion *crypto.CryptoValue - State domain.IDPIntentState + State domain.IDPIntentState + succeededAt time.Time + maxIdPIntentLifetime time.Duration + expiresAt time.Time } -func NewIDPIntentWriteModel(id, resourceOwner string) *IDPIntentWriteModel { +func NewIDPIntentWriteModel(id, resourceOwner string, maxIdPIntentLifetime time.Duration) *IDPIntentWriteModel { return &IDPIntentWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: id, ResourceOwner: resourceOwner, }, + maxIdPIntentLifetime: maxIdPIntentLifetime, } } +func (wm *IDPIntentWriteModel) ExpiresAt() time.Time { + if wm.expiresAt.IsZero() { + return wm.succeededAt.Add(wm.maxIdPIntentLifetime) + } + return wm.expiresAt +} + func (wm *IDPIntentWriteModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { @@ -56,6 +68,8 @@ func (wm *IDPIntentWriteModel) Reduce() error { wm.reduceLDAPSucceededEvent(e) case *idpintent.FailedEvent: wm.reduceFailedEvent(e) + case *idpintent.ConsumedEvent: + wm.reduceConsumedEvent(e) } } return wm.WriteModel.Reduce() @@ -74,6 +88,7 @@ func (wm *IDPIntentWriteModel) Query() *eventstore.SearchQueryBuilder { idpintent.SAMLRequestEventType, idpintent.LDAPSucceededEventType, idpintent.FailedEventType, + idpintent.ConsumedEventType, ). Builder() } @@ -93,6 +108,8 @@ func (wm *IDPIntentWriteModel) reduceSAMLSucceededEvent(e *idpintent.SAMLSucceed wm.IDPUserName = e.IDPUserName wm.Assertion = e.Assertion wm.State = domain.IDPIntentStateSucceeded + wm.succeededAt = e.CreationDate() + wm.expiresAt = e.ExpiresAt } func (wm *IDPIntentWriteModel) reduceLDAPSucceededEvent(e *idpintent.LDAPSucceededEvent) { @@ -102,6 +119,8 @@ func (wm *IDPIntentWriteModel) reduceLDAPSucceededEvent(e *idpintent.LDAPSucceed wm.IDPUserName = e.IDPUserName wm.IDPEntryAttributes = e.EntryAttributes wm.State = domain.IDPIntentStateSucceeded + wm.succeededAt = e.CreationDate() + wm.expiresAt = e.ExpiresAt } func (wm *IDPIntentWriteModel) reduceOAuthSucceededEvent(e *idpintent.SucceededEvent) { @@ -112,6 +131,8 @@ func (wm *IDPIntentWriteModel) reduceOAuthSucceededEvent(e *idpintent.SucceededE wm.IDPAccessToken = e.IDPAccessToken wm.IDPIDToken = e.IDPIDToken wm.State = domain.IDPIntentStateSucceeded + wm.succeededAt = e.CreationDate() + wm.expiresAt = e.ExpiresAt } func (wm *IDPIntentWriteModel) reduceSAMLRequestEvent(e *idpintent.SAMLRequestEvent) { @@ -122,6 +143,10 @@ func (wm *IDPIntentWriteModel) reduceFailedEvent(e *idpintent.FailedEvent) { wm.State = domain.IDPIntentStateFailed } +func (wm *IDPIntentWriteModel) reduceConsumedEvent(e *idpintent.ConsumedEvent) { + wm.State = domain.IDPIntentStateConsumed +} + func IDPIntentAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { return &eventstore.Aggregate{ Type: idpintent.AggregateType, diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 2400b9ee35..1be3971e87 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -4,8 +4,10 @@ import ( "context" "net/url" "testing" + "time" - "github.com/crewjam/saml" + crewjam_saml "github.com/crewjam/saml" + goldap "github.com/go-ldap/ldap/v3" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -26,6 +28,7 @@ import ( "github.com/zitadel/zitadel/internal/idp/providers/ldap" "github.com/zitadel/zitadel/internal/idp/providers/oauth" openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" + "github.com/zitadel/zitadel/internal/idp/providers/saml" rep_idp "github.com/zitadel/zitadel/internal/repository/idp" "github.com/zitadel/zitadel/internal/repository/idpintent" "github.com/zitadel/zitadel/internal/repository/instance" @@ -867,7 +870,7 @@ func TestCommands_SucceedIDPIntent(t *testing.T) { }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "ro"), + writeModel: NewIDPIntentWriteModel("id", "ro", 0), }, res{ err: zerrors.ThrowInternal(nil, "id", "encryption failed"), @@ -888,7 +891,7 @@ func TestCommands_SucceedIDPIntent(t *testing.T) { }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "ro"), + writeModel: NewIDPIntentWriteModel("id", "ro", 0), idpSession: &oauth.Session{ Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ Token: &oauth2.Token{ @@ -922,6 +925,7 @@ func TestCommands_SucceedIDPIntent(t *testing.T) { Crypted: []byte("accessToken"), }, "idToken", + time.Time{}, ) return event }(), @@ -930,7 +934,7 @@ func TestCommands_SucceedIDPIntent(t *testing.T) { }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "instance"), + writeModel: NewIDPIntentWriteModel("id", "instance", 0), idpSession: &openid.Session{ Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ Token: &oauth2.Token{ @@ -973,7 +977,7 @@ func TestCommands_SucceedSAMLIDPIntent(t *testing.T) { ctx context.Context writeModel *IDPIntentWriteModel idpUser idp.User - assertion *saml.Assertion + session *saml.Session userID string } type res struct { @@ -998,7 +1002,7 @@ func TestCommands_SucceedSAMLIDPIntent(t *testing.T) { }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "ro"), + writeModel: NewIDPIntentWriteModel("id", "ro", 0), }, res{ err: zerrors.ThrowInternal(nil, "id", "encryption failed"), @@ -1023,14 +1027,17 @@ func TestCommands_SucceedSAMLIDPIntent(t *testing.T) { KeyID: "id", Crypted: []byte(""), }, + time.Time{}, ), ), ), }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "instance"), - assertion: &saml.Assertion{ID: "id"}, + writeModel: NewIDPIntentWriteModel("id", "instance", 0), + session: &saml.Session{ + Assertion: &crewjam_saml.Assertion{ID: "id"}, + }, idpUser: openid.NewUser(&oidc.UserInfo{ Subject: "id", UserInfoProfile: oidc.UserInfoProfile{ @@ -1061,14 +1068,17 @@ func TestCommands_SucceedSAMLIDPIntent(t *testing.T) { KeyID: "id", Crypted: []byte(""), }, + time.Time{}, ), ), ), }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "instance"), - assertion: &saml.Assertion{ID: "id"}, + writeModel: NewIDPIntentWriteModel("id", "instance", 0), + session: &saml.Session{ + Assertion: &crewjam_saml.Assertion{ID: "id"}, + }, idpUser: openid.NewUser(&oidc.UserInfo{ Subject: "id", UserInfoProfile: oidc.UserInfoProfile{ @@ -1088,7 +1098,7 @@ func TestCommands_SucceedSAMLIDPIntent(t *testing.T) { eventstore: tt.fields.eventstore(t), idpConfigEncryption: tt.fields.idpConfigEncryption, } - got, err := c.SucceedSAMLIDPIntent(tt.args.ctx, tt.args.writeModel, tt.args.idpUser, tt.args.userID, tt.args.assertion) + got, err := c.SucceedSAMLIDPIntent(tt.args.ctx, tt.args.writeModel, tt.args.idpUser, tt.args.userID, tt.args.session) require.ErrorIs(t, err, tt.res.err) assert.Equal(t, tt.res.token, got) }) @@ -1128,7 +1138,7 @@ func TestCommands_RequestSAMLIDPIntent(t *testing.T) { }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "instance"), + writeModel: NewIDPIntentWriteModel("id", "instance", 0), request: "request", }, res{}, @@ -1156,7 +1166,7 @@ func TestCommands_SucceedLDAPIDPIntent(t *testing.T) { writeModel *IDPIntentWriteModel idpUser idp.User userID string - attributes map[string][]string + session *ldap.Session } type res struct { token string @@ -1180,7 +1190,7 @@ func TestCommands_SucceedLDAPIDPIntent(t *testing.T) { }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "instance"), + writeModel: NewIDPIntentWriteModel("id", "instance", 0), }, res{ err: zerrors.ThrowInternal(nil, "id", "encryption failed"), @@ -1200,14 +1210,24 @@ func TestCommands_SucceedLDAPIDPIntent(t *testing.T) { "username", "", map[string][]string{"id": {"id"}}, + time.Time{}, ), ), ), }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "instance"), - attributes: map[string][]string{"id": {"id"}}, + writeModel: NewIDPIntentWriteModel("id", "instance", 0), + session: &ldap.Session{ + Entry: &goldap.Entry{ + Attributes: []*goldap.EntryAttribute{ + { + Name: "id", + Values: []string{"id"}, + }, + }, + }, + }, idpUser: ldap.NewUser( "id", "", @@ -1235,7 +1255,7 @@ func TestCommands_SucceedLDAPIDPIntent(t *testing.T) { eventstore: tt.fields.eventstore(t), idpConfigEncryption: tt.fields.idpConfigEncryption, } - got, err := c.SucceedLDAPIDPIntent(tt.args.ctx, tt.args.writeModel, tt.args.idpUser, tt.args.userID, tt.args.attributes) + got, err := c.SucceedLDAPIDPIntent(tt.args.ctx, tt.args.writeModel, tt.args.idpUser, tt.args.userID, tt.args.session) require.ErrorIs(t, err, tt.res.err) assert.Equal(t, tt.res.token, got) }) @@ -1275,7 +1295,7 @@ func TestCommands_FailIDPIntent(t *testing.T) { }, args{ ctx: context.Background(), - writeModel: NewIDPIntentWriteModel("id", "instance"), + writeModel: NewIDPIntentWriteModel("id", "instance", 0), reason: "reason", }, res{ diff --git a/internal/command/session.go b/internal/command/session.go index d00e541e62..3c06c22967 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -17,6 +17,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/notification/senders" + "github.com/zitadel/zitadel/internal/repository/idpintent" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -32,31 +33,33 @@ type SessionCommands struct { eventstore *eventstore.Eventstore eventCommands []eventstore.Command - hasher *crypto.Hasher - intentAlg crypto.EncryptionAlgorithm - totpAlg crypto.EncryptionAlgorithm - otpAlg crypto.EncryptionAlgorithm - createCode encryptedCodeWithDefaultFunc - createPhoneCode encryptedCodeGeneratorWithDefaultFunc - createToken func(sessionID string) (id string, token string, err error) - getCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) - now func() time.Time + hasher *crypto.Hasher + intentAlg crypto.EncryptionAlgorithm + totpAlg crypto.EncryptionAlgorithm + otpAlg crypto.EncryptionAlgorithm + createCode encryptedCodeWithDefaultFunc + createPhoneCode encryptedCodeGeneratorWithDefaultFunc + createToken func(sessionID string) (id string, token string, err error) + getCodeVerifier func(ctx context.Context, id string) (senders.CodeGenerator, error) + now func() time.Time + maxIdPIntentLifetime time.Duration } func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWriteModel) *SessionCommands { return &SessionCommands{ - sessionCommands: cmds, - sessionWriteModel: session, - eventstore: c.eventstore, - hasher: c.userPasswordHasher, - intentAlg: c.idpConfigEncryption, - totpAlg: c.multifactors.OTP.CryptoMFA, - otpAlg: c.userEncryption, - createCode: c.newEncryptedCodeWithDefault, - createPhoneCode: c.newPhoneCode, - createToken: c.sessionTokenCreator, - getCodeVerifier: c.phoneCodeVerifierFromConfig, - now: time.Now, + sessionCommands: cmds, + sessionWriteModel: session, + eventstore: c.eventstore, + hasher: c.userPasswordHasher, + intentAlg: c.idpConfigEncryption, + totpAlg: c.multifactors.OTP.CryptoMFA, + otpAlg: c.userEncryption, + createCode: c.newEncryptedCodeWithDefault, + createPhoneCode: c.newPhoneCode, + createToken: c.sessionTokenCreator, + getCodeVerifier: c.phoneCodeVerifierFromConfig, + now: time.Now, + maxIdPIntentLifetime: c.maxIdPIntentLifetime, } } @@ -92,7 +95,7 @@ func CheckIntent(intentID, token string) SessionCommand { if err := crypto.CheckToken(cmd.intentAlg, token, intentID); err != nil { return nil, err } - cmd.intentWriteModel = NewIDPIntentWriteModel(intentID, "") + cmd.intentWriteModel = NewIDPIntentWriteModel(intentID, "", cmd.maxIdPIntentLifetime) err := cmd.eventstore.FilterToQueryReducer(ctx, cmd.intentWriteModel) if err != nil { return nil, err @@ -100,6 +103,9 @@ func CheckIntent(intentID, token string) SessionCommand { if cmd.intentWriteModel.State != domain.IDPIntentStateSucceeded { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Df4bw", "Errors.Intent.NotSucceeded") } + if time.Now().After(cmd.intentWriteModel.ExpiresAt()) { + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-SAf42", "Errors.Intent.Expired") + } if cmd.intentWriteModel.UserID != "" { if cmd.intentWriteModel.UserID != cmd.sessionWriteModel.UserID { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser") @@ -168,6 +174,7 @@ func (s *SessionCommands) PasswordChecked(ctx context.Context, checkedAt time.Ti func (s *SessionCommands) IntentChecked(ctx context.Context, checkedAt time.Time) { s.eventCommands = append(s.eventCommands, session.NewIntentCheckedEvent(ctx, s.sessionWriteModel.aggregate, checkedAt)) + s.eventCommands = append(s.eventCommands, idpintent.NewConsumedEvent(ctx, IDPIntentAggregateFromWriteModel(&s.intentWriteModel.WriteModel))) } func (s *SessionCommands) WebAuthNChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement, rpid string) { diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 60027d3a05..e65f32fb57 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -695,6 +695,7 @@ func TestCommands_updateSession(t *testing.T) { "userID2", nil, "", + time.Now().Add(time.Hour), ), ), ), @@ -757,6 +758,111 @@ func TestCommands_updateSession(t *testing.T) { err: zerrors.ThrowPermissionDenied(nil, "CRYPTO-CRYPTO", "Errors.Intent.InvalidToken"), }, }, + { + "set user, intent token already consumed", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "", "", "", "", language.English, domain.GenderUnspecified, "", false), + ), + eventFromEventPusher( + idpintent.NewSucceededEvent(context.Background(), + &idpintent.NewAggregate("intent", "instance1").Aggregate, + nil, + "idpUserID", + "idpUsername", + "userID", + nil, + "", + time.Now().Add(time.Hour), + ), + ), + eventFromEventPusher( + idpintent.NewConsumedEvent(context.Background(), + &idpintent.NewAggregate("intent", "instance1").Aggregate, + ), + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instance1", "", ""), + checks: &SessionCommands{ + sessionWriteModel: NewSessionWriteModel("sessionID", "instance1"), + sessionCommands: []SessionCommand{ + CheckUser("userID", "org1", &language.Afrikaans), + CheckIntent("intent", "aW50ZW50"), + }, + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + intentAlg: decryption(nil), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Df4bw", "Errors.Intent.NotSucceeded"), + }, + }, + { + "set user, intent token already expired", + fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "", "", "", "", language.English, domain.GenderUnspecified, "", false), + ), + eventFromEventPusher( + idpintent.NewSucceededEvent(context.Background(), + &idpintent.NewAggregate("intent", "instance1").Aggregate, + nil, + "idpUserID", + "idpUsername", + "userID", + nil, + "", + time.Now().Add(-time.Hour), + ), + ), + ), + ), + }, + args{ + ctx: authz.NewMockContext("instance1", "", ""), + checks: &SessionCommands{ + sessionWriteModel: NewSessionWriteModel("sessionID", "instance1"), + sessionCommands: []SessionCommand{ + CheckUser("userID", "org1", &language.Afrikaans), + CheckIntent("intent", "aW50ZW50"), + }, + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + intentAlg: decryption(nil), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-SAf42", "Errors.Intent.Expired"), + }, + }, { "set user, intent, metadata and token", fields{ @@ -768,13 +874,14 @@ func TestCommands_updateSession(t *testing.T) { ), eventFromEventPusher( idpintent.NewSucceededEvent(context.Background(), - &idpintent.NewAggregate("id", "instance1").Aggregate, + &idpintent.NewAggregate("intent", "instance1").Aggregate, nil, "idpUserID", "idpUsername", "userID", nil, "", + time.Now().Add(time.Hour), ), ), ), @@ -783,6 +890,7 @@ func TestCommands_updateSession(t *testing.T) { "userID", "org1", testNow, &language.Afrikaans), session.NewIntentCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instance1").Aggregate, testNow), + idpintent.NewConsumedEvent(context.Background(), &idpintent.NewAggregate("intent", "org1").Aggregate), session.NewMetadataSetEvent(context.Background(), &session.NewAggregate("sessionID", "instance1").Aggregate, map[string][]byte{"key": []byte("value")}), session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "instance1").Aggregate, @@ -842,13 +950,14 @@ func TestCommands_updateSession(t *testing.T) { ), eventFromEventPusher( idpintent.NewSucceededEvent(context.Background(), - &idpintent.NewAggregate("id", "instance1").Aggregate, + &idpintent.NewAggregate("intent", "instance1").Aggregate, nil, "idpUserID", "idpUsername", "", nil, "", + time.Now().Add(time.Hour), ), ), ), @@ -866,6 +975,7 @@ func TestCommands_updateSession(t *testing.T) { "userID", "org1", testNow, &language.Afrikaans), session.NewIntentCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "instance1").Aggregate, testNow), + idpintent.NewConsumedEvent(context.Background(), &idpintent.NewAggregate("intent", "org1").Aggregate), session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "instance1").Aggregate, "tokenID"), ), diff --git a/internal/config/systemdefaults/system_defaults.go b/internal/config/systemdefaults/system_defaults.go index f6d39befe7..827dd61f73 100644 --- a/internal/config/systemdefaults/system_defaults.go +++ b/internal/config/systemdefaults/system_defaults.go @@ -7,15 +7,16 @@ import ( ) type SystemDefaults struct { - SecretGenerators SecretGenerators - PasswordHasher crypto.HashConfig - SecretHasher crypto.HashConfig - Multifactors MultifactorConfig - DomainVerification DomainVerification - Notifications Notifications - KeyConfig KeyConfig - DefaultQueryLimit uint64 - MaxQueryLimit uint64 + SecretGenerators SecretGenerators + PasswordHasher crypto.HashConfig + SecretHasher crypto.HashConfig + Multifactors MultifactorConfig + DomainVerification DomainVerification + Notifications Notifications + KeyConfig KeyConfig + DefaultQueryLimit uint64 + MaxQueryLimit uint64 + MaxIdPIntentLifetime time.Duration } type SecretGenerators struct { diff --git a/internal/domain/idp.go b/internal/domain/idp.go index e2571f6b0d..bea106298b 100644 --- a/internal/domain/idp.go +++ b/internal/domain/idp.go @@ -115,6 +115,7 @@ const ( IDPIntentStateStarted IDPIntentStateSucceeded IDPIntentStateFailed + IDPIntentStateConsumed idpIntentStateCount ) diff --git a/internal/idp/providers/apple/session.go b/internal/idp/providers/apple/session.go index eee68fa2a5..9395d84b2b 100644 --- a/internal/idp/providers/apple/session.go +++ b/internal/idp/providers/apple/session.go @@ -10,6 +10,8 @@ import ( "github.com/zitadel/zitadel/internal/idp/providers/oidc" ) +var _ idp.Session = (*Session)(nil) + // Session extends the [oidc.Session] with the formValues returned from the callback. // This enables to parse the user (name and email), which Apple only returns as form params on registration type Session struct { diff --git a/internal/idp/providers/azuread/session.go b/internal/idp/providers/azuread/session.go index 4b0a6fb844..169784fb58 100644 --- a/internal/idp/providers/azuread/session.go +++ b/internal/idp/providers/azuread/session.go @@ -3,6 +3,7 @@ package azuread import ( "context" "net/http" + "time" "github.com/zitadel/oidc/v3/pkg/client/rp" httphelper "github.com/zitadel/oidc/v3/pkg/http" @@ -12,6 +13,8 @@ import ( "github.com/zitadel/zitadel/internal/idp/providers/oauth" ) +var _ idp.Session = (*Session)(nil) + // Session extends the [oauth.Session] to be able to handle the id_token and to implement the [idp.SessionSupportsMigration] functionality type Session struct { *Provider @@ -79,6 +82,13 @@ func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { return user, nil } +func (s *Session) ExpiresAt() time.Time { + if s.OAuthSession == nil { + return time.Time{} + } + return s.OAuthSession.ExpiresAt() +} + // Tokens returns the [oidc.Tokens] of the underlying [oauth.Session]. func (s *Session) Tokens() *oidc.Tokens[*oidc.IDTokenClaims] { return s.oauth().Tokens diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go index 6df08a6998..5138812f3c 100644 --- a/internal/idp/providers/jwt/session.go +++ b/internal/idp/providers/jwt/session.go @@ -57,6 +57,13 @@ func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { return &User{s.Tokens.IDTokenClaims}, nil } +func (s *Session) ExpiresAt() time.Time { + if s.Tokens == nil || s.Tokens.IDTokenClaims == nil { + return time.Time{} + } + return s.Tokens.IDTokenClaims.GetExpiration() +} + func (s *Session) validateToken(ctx context.Context, token string) (*oidc.IDTokenClaims, error) { logging.Debug("begin token validation") // TODO: be able to specify them in the template: https://github.com/zitadel/zitadel/issues/5322 diff --git a/internal/idp/providers/ldap/session.go b/internal/idp/providers/ldap/session.go index 0a6a87ba3d..1679e35b61 100644 --- a/internal/idp/providers/ldap/session.go +++ b/internal/idp/providers/ldap/session.go @@ -96,6 +96,10 @@ func (s *Session) FetchUser(_ context.Context) (_ idp.User, err error) { ) } +func (s *Session) ExpiresAt() time.Time { + return time.Time{} // falls back to the default expiration time +} + func tryBind( server string, startTLS bool, diff --git a/internal/idp/providers/oauth/session.go b/internal/idp/providers/oauth/session.go index 247a7f8710..c9e175d1cf 100644 --- a/internal/idp/providers/oauth/session.go +++ b/internal/idp/providers/oauth/session.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "time" "github.com/zitadel/oidc/v3/pkg/client/rp" httphelper "github.com/zitadel/oidc/v3/pkg/http" @@ -69,6 +70,13 @@ func (s *Session) FetchUser(ctx context.Context) (_ idp.User, err error) { return user, nil } +func (s *Session) ExpiresAt() time.Time { + if s.Tokens == nil { + return time.Time{} + } + return s.Tokens.Expiry +} + func (s *Session) authorize(ctx context.Context) (err error) { if s.Code == "" { return ErrCodeMissing diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go index b17a3b0a0b..430a14e5bb 100644 --- a/internal/idp/providers/oidc/session.go +++ b/internal/idp/providers/oidc/session.go @@ -3,6 +3,7 @@ package oidc import ( "context" "errors" + "time" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" @@ -72,6 +73,13 @@ func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { return u, nil } +func (s *Session) ExpiresAt() time.Time { + if s.Tokens == nil { + return time.Time{} + } + return s.Tokens.Expiry +} + func (s *Session) Authorize(ctx context.Context) (err error) { if s.Code == "" { return ErrCodeMissing diff --git a/internal/idp/providers/saml/session.go b/internal/idp/providers/saml/session.go index b0748d33a3..e2a1655a26 100644 --- a/internal/idp/providers/saml/session.go +++ b/internal/idp/providers/saml/session.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/url" + "time" "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" @@ -107,6 +108,13 @@ func (s *Session) FetchUser(ctx context.Context) (user idp.User, err error) { return userMapper, nil } +func (s *Session) ExpiresAt() time.Time { + if s.Assertion == nil || s.Assertion.Conditions == nil { + return time.Time{} + } + return s.Assertion.Conditions.NotOnOrAfter +} + func (s *Session) transientMappingID() (string, error) { for _, statement := range s.Assertion.AttributeStatements { for _, attribute := range statement.Attributes { diff --git a/internal/idp/session.go b/internal/idp/session.go index ab54bcabaa..fc593eb820 100644 --- a/internal/idp/session.go +++ b/internal/idp/session.go @@ -2,6 +2,7 @@ package idp import ( "context" + "time" ) // Session is the minimal implementation for a session of a 3rd party authentication [Provider] @@ -9,6 +10,7 @@ type Session interface { GetAuth(ctx context.Context) (content string, redirect bool) PersistentParameters() map[string]any FetchUser(ctx context.Context) (User, error) + ExpiresAt() time.Time } // SessionSupportsMigration is an optional extension to the Session interface. diff --git a/internal/integration/client.go b/internal/integration/client.go index e82a6bec55..f1bcfb41bd 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -672,6 +672,23 @@ func (i *Instance) CreatePasswordSession(t *testing.T, ctx context.Context, user createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() } +func (i *Instance) CreateIntentSession(t *testing.T, ctx context.Context, userID, intentID, intentToken string) (id, token string, start, change time.Time) { + createResp, err := i.Client.SessionV2.CreateSession(ctx, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{UserId: userID}, + }, + IdpIntent: &session.CheckIDPIntent{ + IdpIntentId: intentID, + IdpIntentToken: intentToken, + }, + }, + }) + require.NoError(t, err) + return createResp.GetSessionId(), createResp.GetSessionToken(), + createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() +} + func (i *Instance) CreateProjectGrant(ctx context.Context, projectID, grantedOrgID string) *mgmt.AddProjectGrantResponse { resp, err := i.Client.Mgmt.AddProjectGrant(ctx, &mgmt.AddProjectGrantRequest{ GrantedOrgId: grantedOrgID, diff --git a/internal/integration/sink/server.go b/internal/integration/sink/server.go index 633ebf424f..8abb31a63e 100644 --- a/internal/integration/sink/server.go +++ b/internal/integration/sink/server.go @@ -17,6 +17,7 @@ import ( crewjam_saml "github.com/crewjam/saml" "github.com/go-chi/chi/v5" + goldap "github.com/go-ldap/ldap/v3" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" "github.com/zitadel/logging" @@ -48,7 +49,7 @@ func CallURL(ch Channel) string { return u.String() } -func SuccessfulOAuthIntent(instanceID, idpID, idpUserID, userID string) (string, string, time.Time, uint64, error) { +func SuccessfulOAuthIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) { u := url.URL{ Scheme: "http", Host: host, @@ -59,6 +60,7 @@ func SuccessfulOAuthIntent(instanceID, idpID, idpUserID, userID string) (string, IDPID: idpID, IDPUserID: idpUserID, UserID: userID, + Expiry: expiry, }) if err != nil { return "", "", time.Time{}, uint64(0), err @@ -66,7 +68,7 @@ func SuccessfulOAuthIntent(instanceID, idpID, idpUserID, userID string) (string, return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil } -func SuccessfulOIDCIntent(instanceID, idpID, idpUserID, userID string) (string, string, time.Time, uint64, error) { +func SuccessfulOIDCIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) { u := url.URL{ Scheme: "http", Host: host, @@ -77,6 +79,7 @@ func SuccessfulOIDCIntent(instanceID, idpID, idpUserID, userID string) (string, IDPID: idpID, IDPUserID: idpUserID, UserID: userID, + Expiry: expiry, }) if err != nil { return "", "", time.Time{}, uint64(0), err @@ -84,7 +87,7 @@ func SuccessfulOIDCIntent(instanceID, idpID, idpUserID, userID string) (string, return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil } -func SuccessfulSAMLIntent(instanceID, idpID, idpUserID, userID string) (string, string, time.Time, uint64, error) { +func SuccessfulSAMLIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) { u := url.URL{ Scheme: "http", Host: host, @@ -95,6 +98,7 @@ func SuccessfulSAMLIntent(instanceID, idpID, idpUserID, userID string) (string, IDPID: idpID, IDPUserID: idpUserID, UserID: userID, + Expiry: expiry, }) if err != nil { return "", "", time.Time{}, uint64(0), err @@ -282,10 +286,11 @@ func readLoop(ws *websocket.Conn) (done chan error) { } type SuccessfulIntentRequest struct { - InstanceID string `json:"instance_id"` - IDPID string `json:"idp_id"` - IDPUserID string `json:"idp_user_id"` - UserID string `json:"user_id"` + InstanceID string `json:"instance_id"` + IDPID string `json:"idp_id"` + IDPUserID string `json:"idp_user_id"` + UserID string `json:"user_id"` + Expiry time.Time `json:"expiry"` } type SuccessfulIntentResponse struct { IntentID string `json:"intent_id"` @@ -376,6 +381,7 @@ func createSuccessfulOAuthIntent(ctx context.Context, cmd *command.Commands, req Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ Token: &oauth2.Token{ AccessToken: "accessToken", + Expiry: req.Expiry, }, IDToken: "idToken", }, @@ -407,6 +413,7 @@ func createSuccessfulOIDCIntent(ctx context.Context, cmd *command.Commands, req Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ Token: &oauth2.Token{ AccessToken: "accessToken", + Expiry: req.Expiry, }, IDToken: "idToken", }, @@ -431,9 +438,16 @@ func createSuccessfulSAMLIntent(ctx context.Context, cmd *command.Commands, req ID: req.IDPUserID, Attributes: map[string][]string{"attribute1": {"value1"}}, } - assertion := &crewjam_saml.Assertion{ID: "id"} + session := &saml.Session{ + Assertion: &crewjam_saml.Assertion{ + ID: "id", + Conditions: &crewjam_saml.Conditions{ + NotOnOrAfter: req.Expiry, + }, + }, + } - token, err := cmd.SucceedSAMLIDPIntent(ctx, writeModel, idpUser, req.UserID, assertion) + token, err := cmd.SucceedSAMLIDPIntent(ctx, writeModel, idpUser, req.UserID, session) if err != nil { return nil, err } @@ -465,8 +479,14 @@ func createSuccessfulLDAPIntent(ctx context.Context, cmd *command.Commands, req "", "", ) - attributes := map[string][]string{"id": {req.IDPUserID}, "username": {username}, "language": {lang.String()}} - token, err := cmd.SucceedLDAPIDPIntent(ctx, writeModel, idpUser, req.UserID, attributes) + session := &ldap.Session{Entry: &goldap.Entry{ + Attributes: []*goldap.EntryAttribute{ + {Name: "id", Values: []string{req.IDPUserID}}, + {Name: "username", Values: []string{username}}, + {Name: "language", Values: []string{lang.String()}}, + }, + }} + token, err := cmd.SucceedLDAPIDPIntent(ctx, writeModel, idpUser, req.UserID, session) if err != nil { return nil, err } diff --git a/internal/repository/idpintent/eventstore.go b/internal/repository/idpintent/eventstore.go index ea94803973..6bec32c735 100644 --- a/internal/repository/idpintent/eventstore.go +++ b/internal/repository/idpintent/eventstore.go @@ -11,4 +11,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SAMLRequestEventType, SAMLRequestEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, LDAPSucceededEventType, LDAPSucceededEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, FailedEventType, FailedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, ConsumedEventType, eventstore.GenericEventMapper[ConsumedEvent]) } diff --git a/internal/repository/idpintent/intent.go b/internal/repository/idpintent/intent.go index 27e6391f95..e4ee28cae9 100644 --- a/internal/repository/idpintent/intent.go +++ b/internal/repository/idpintent/intent.go @@ -3,6 +3,7 @@ package idpintent import ( "context" "net/url" + "time" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" @@ -16,6 +17,7 @@ const ( SAMLRequestEventType = instanceEventTypePrefix + "saml.requested" LDAPSucceededEventType = instanceEventTypePrefix + "ldap.succeeded" FailedEventType = instanceEventTypePrefix + "failed" + ConsumedEventType = instanceEventTypePrefix + "consumed" ) type StartedEvent struct { @@ -79,6 +81,7 @@ type SucceededEvent struct { IDPAccessToken *crypto.CryptoValue `json:"idpAccessToken,omitempty"` IDPIDToken string `json:"idpIdToken,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` } func NewSucceededEvent( @@ -90,6 +93,7 @@ func NewSucceededEvent( userID string, idpAccessToken *crypto.CryptoValue, idpIDToken string, + expiresAt time.Time, ) *SucceededEvent { return &SucceededEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -103,6 +107,7 @@ func NewSucceededEvent( UserID: userID, IDPAccessToken: idpAccessToken, IDPIDToken: idpIDToken, + ExpiresAt: expiresAt, } } @@ -136,6 +141,7 @@ type SAMLSucceededEvent struct { UserID string `json:"userId,omitempty"` Assertion *crypto.CryptoValue `json:"assertion,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` } func NewSAMLSucceededEvent( @@ -146,6 +152,7 @@ func NewSAMLSucceededEvent( idpUserName, userID string, assertion *crypto.CryptoValue, + expiresAt time.Time, ) *SAMLSucceededEvent { return &SAMLSucceededEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -158,6 +165,7 @@ func NewSAMLSucceededEvent( IDPUserName: idpUserName, UserID: userID, Assertion: assertion, + ExpiresAt: expiresAt, } } @@ -233,6 +241,7 @@ type LDAPSucceededEvent struct { UserID string `json:"userId,omitempty"` EntryAttributes map[string][]string `json:"user,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` } func NewLDAPSucceededEvent( @@ -243,6 +252,7 @@ func NewLDAPSucceededEvent( idpUserName, userID string, attributes map[string][]string, + expiresAt time.Time, ) *LDAPSucceededEvent { return &LDAPSucceededEvent{ BaseEvent: *eventstore.NewBaseEventForPush( @@ -255,6 +265,7 @@ func NewLDAPSucceededEvent( IDPUserName: idpUserName, UserID: userID, EntryAttributes: attributes, + ExpiresAt: expiresAt, } } @@ -320,3 +331,32 @@ func FailedEventMapper(event eventstore.Event) (eventstore.Event, error) { return e, nil } + +type ConsumedEvent struct { + eventstore.BaseEvent `json:"-"` +} + +func NewConsumedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, +) *ConsumedEvent { + return &ConsumedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + ConsumedEventType, + ), + } +} + +func (e *ConsumedEvent) Payload() interface{} { + return e +} + +func (e *ConsumedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *ConsumedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = *base +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index d7dc18898b..8254b82b45 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -554,6 +554,7 @@ Errors: StateMissing: В заявката липсва параметър състояние NotStarted: Намерението не е стартирано или вече е прекратено NotSucceeded: Намерението не е успешно + Expired: Намерението е изтекло TokenCreationFailed: Неуспешно създаване на токен InvalidToken: Знакът за намерение е невалиден OtherUser: Намерение, предназначено за друг потребител diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 80db4952f9..bb4172fbff 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -534,6 +534,7 @@ Errors: StateMissing: V požadavku chybí parametr stavu NotStarted: Záměr nebyl zahájen nebo již byl ukončen NotSucceeded: Záměr nebyl úspěšný + Expired: Záměr vypršel TokenCreationFailed: Vytvoření tokenu selhalo InvalidToken: Token záměru je neplatný OtherUser: Záměr určený pro jiného uživatele diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index dcb3ac5c71..a24ce7c933 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: State parameter fehlt im Request NotStarted: Intent wurde nicht gestartet oder wurde bereits beendet NotSucceeded: Intent war nicht erfolgreich + Expired: Intent ist abgelaufen TokenCreationFailed: Tokenerstellung schlug fehl InvalidToken: Intent Token ist ungültig OtherUser: Intent ist für anderen Benutzer gedacht diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index bd8d26d727..e8f2781de1 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -537,6 +537,7 @@ Errors: StateMissing: State parameter is missing in the request NotStarted: Intent is not started or was already terminated NotSucceeded: Intent has not succeeded + Expired: Intent has expired TokenCreationFailed: Token creation failed InvalidToken: Intent Token is invalid OtherUser: Intent meant for another user diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 9f11b63964..b91d055f70 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: Falta un parámetro de estado en la solicitud NotStarted: La intención no se ha iniciado o ya ha finalizado NotSucceeded: Intento fallido + Expired: La intención ha expirado TokenCreationFailed: Fallo en la creación del token InvalidToken: El token de la intención no es válido OtherUser: Destinado a otro usuario diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index ff8393befc..98f2bee9a0 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: Paramètre d'état manquant dans la requête NotStarted: Intent n'a pas démarré ou s'est déjà terminé NotSucceeded: l'intention n'a pas abouti + Expired: L'intention a expiré TokenCreationFailed: La création du token a échoué InvalidToken: Le jeton d'intention n'est pas valide OtherUser: Intention destinée à un autre utilisateur diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index b17c6a1225..5becd6e606 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: A kérésből hiányzik a State paraméter NotStarted: Az intent nem indult el, vagy már befejeződött NotSucceeded: Az intent nem sikerült + Expired: A kérésből lejárt TokenCreationFailed: A token létrehozása nem sikerült InvalidToken: Az Intent Token érvénytelen OtherUser: Az intent egy másik felhasználónak szól diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 56a454e71d..0108d7618b 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: Parameter status tidak ada dalam permintaan NotStarted: Niat belum dimulai atau sudah dihentikan NotSucceeded: Niatnya belum berhasil + Expired: Kode sudah habis masa berlakunya TokenCreationFailed: Pembuatan token gagal InvalidToken: Token Niat tidak valid OtherUser: Maksudnya ditujukan untuk pengguna lain diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 6713abf2e1..750c48471a 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: parametro di stato mancante nella richiesta NotStarted: l'intento non è stato avviato o è già stato terminato NotSucceeded: l'intento non è andato a buon fine + Expired: L'intento è scaduto TokenCreationFailed: creazione del token fallita InvalidToken: Il token dell'intento non è valido OtherUser: Intento destinato a un altro utente diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index f57d0f6661..fcd7920999 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -537,6 +537,7 @@ Errors: StateMissing: リクエストに State パラメータがありません NotStarted: インテントが開始されなかったか、既に終了している NotSucceeded: インテントが成功しなかった + Expired: 意図の有効期限が切れました TokenCreationFailed: トークンの作成に失敗しました InvalidToken: インテントのトークンが無効である OtherUser: 他のユーザーを意図している diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index d238142e01..d83af62235 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -537,6 +537,7 @@ Errors: StateMissing: 요청에 상태 매개변수가 누락되었습니다 NotStarted: 의도가 시작되지 않았거나 이미 종료되었습니다 NotSucceeded: 의도가 성공하지 않았습니다 + Expired: 의도의 유효 기간이 만료되었습니다 TokenCreationFailed: 토큰 생성 실패 InvalidToken: 의도 토큰이 유효하지 않습니다 OtherUser: 다른 사용자를 위한 의도입니다 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 898ed67360..7126925279 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -535,6 +535,7 @@ Errors: StateMissing: Параметарот State недостасува во барањето NotStarted: Намерата не е започната или веќе завршена NotSucceeded: Намерата не е успешна + Expired: Намерата е истечена TokenCreationFailed: Неуспешно креирање на токен InvalidToken: Токенот за намера е невалиден OtherUser: Намерата е за друг корисник diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index 882c58a4f2..a398e4b770 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: Staat parameter ontbreekt in het verzoek NotStarted: Intentie is niet gestart of was al beëindigd NotSucceeded: Intentie is niet geslaagd + Expired: Intentie is verlopen TokenCreationFailed: Token aanmaken mislukt InvalidToken: Intentie Token is ongeldig OtherUser: Intentie bedoeld voor een andere gebruiker diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 13125bc2a9..049a189930 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: Brak parametru stanu w żądaniu NotStarted: Intencja nie została rozpoczęta lub już się zakończyła NotSucceeded: intencja nie powiodła się + Expired: Intencja wygasła TokenCreationFailed: Tworzenie tokena nie powiodło się InvalidToken: Token intencji jest nieprawidłowy OtherUser: Intencja przeznaczona dla innego użytkownika diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 4ab3573c2b..09a5fc02c5 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -535,6 +535,7 @@ Errors: StateMissing: O parâmetro de estado está faltando na solicitação NotStarted: A intenção não foi iniciada ou já foi encerrada NotSucceeded: A intenção não teve sucesso + Expired: A intenção expirou TokenCreationFailed: Falha na criação do token InvalidToken: O token da intenção é inválido OtherUser: Intenção destinada a outro usuário diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index 48790da9e5..9010e57032 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -537,6 +537,7 @@ Errors: StateMissing: Parametrul de stare lipsește în cerere NotStarted: Intenția nu este pornită sau a fost deja terminată NotSucceeded: Intenția nu a reușit + Expired: Intenția a expirat TokenCreationFailed: Crearea token-ului a eșuat InvalidToken: Token-ul intenției este invalid OtherUser: Intenția este destinată altui utilizator diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 64a8ef8013..38b2847637 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -525,6 +525,7 @@ Errors: StateMissing: В запросе отсутствует параметр State NotStarted: Намерение не начато или уже прекращено NotSucceeded: Намерение не увенчалось успехом + Epired: Намерение истекло TokenCreationFailed: Не удалось создать токен InvalidToken: Маркер намерения недействителен OtherUser: Намерение, предназначенное для другого пользователя diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index 2c292976d3..ed4b863886 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: State-parameter saknas i begäran NotStarted: Avsikten har inte startat eller har redan avslutats NotSucceeded: Avsikten har inte lyckats + Expired: Avsikten har gått ut TokenCreationFailed: Token-skapande misslyckades InvalidToken: Avsiktstoken är ogiltig OtherUser: Avsikten är avsedd för en annan användare diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index d4b36df7ff..03aa168a50 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -536,6 +536,7 @@ Errors: StateMissing: 请求中缺少状态参数 NotStarted: 意图没有开始或已经结束 NotSucceeded: 意图不成功 + Expired: 意图已过期 TokenCreationFailed: 令牌创建失败 InvalidToken: 意图令牌是无效的 OtherUser: 意图是为另一个用户准备的 From a626678004ee6a6ee02d814af2daebf673deed15 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Tue, 6 May 2025 08:15:45 +0200 Subject: [PATCH 038/123] fix(setup): execute s54 (#9849) # Which Problems Are Solved Step 54 was not executed during setup. # How the Problems Are Solved Added the step to setup jobs # Additional Changes none # Additional Context - the step was added in https://github.com/zitadel/zitadel/pull/9837 - thanks to @zhirschtritt for raising this. --- cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 ++ 2 files changed, 3 insertions(+) diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 4742b94c7b..127f9d7599 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -150,6 +150,7 @@ type Steps struct { s51IDPTemplate6RootCA *IDPTemplate6RootCA s52IDPTemplate6LDAP2 *IDPTemplate6LDAP2 s53InitPermittedOrgsFunction *InitPermittedOrgsFunction53 + s54InstancePositionIndex *InstancePositionIndex } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index f4df9fc71b..eead1980ed 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -212,6 +212,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s51IDPTemplate6RootCA = &IDPTemplate6RootCA{dbClient: dbClient} steps.s52IDPTemplate6LDAP2 = &IDPTemplate6LDAP2{dbClient: dbClient} steps.s53InitPermittedOrgsFunction = &InitPermittedOrgsFunction53{dbClient: dbClient} + steps.s54InstancePositionIndex = &InstancePositionIndex{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -254,6 +255,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s51IDPTemplate6RootCA, steps.s52IDPTemplate6LDAP2, steps.s53InitPermittedOrgsFunction, + steps.s54InstancePositionIndex, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { From 8cb1d24b36d4c7a588210c1a1649fa3b2a77dc7d Mon Sep 17 00:00:00 2001 From: Zach Hirschtritt Date: Tue, 6 May 2025 02:38:19 -0400 Subject: [PATCH 039/123] fix: add user id index on sessions8 (#9834) # Which Problems Are Solved When a user changes their password, Zitadel needs to terminate all of that user's active sessions. This query can take many seconds on deployments with large session and user tables. This happens as part of session projection handling, so doesn't directly impact user experience, but potentially bogs down the projection handler which isn't great. In the future, this index could be used to power a "see all of my current sessions" feature in Zitadel. # How the Problems Are Solved Adds new index on `user_id` column on `projections.sessions8` table. Alternatively, we can index on `(instance_id, user_id)` instead but opted for keeping the index smaller as we already index on `instance_id` separately. # Additional Changes None # Additional Context None --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- internal/query/projection/session.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index 196a352190..53eba54efb 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -87,6 +87,7 @@ func (*sessionProjection) Init() *old_handler.Check { SessionColumnUserAgentFingerprintID+"_idx", []string{SessionColumnUserAgentFingerprintID}, )), + handler.WithIndex(handler.NewIndex(SessionColumnUserID+"_idx", []string{SessionColumnUserID})), ), ) } From 0d7d4e6af084153d9a778e1f03561ebd5e35b89e Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 7 May 2025 10:14:01 +0200 Subject: [PATCH 040/123] docs: extend api design with additional information and examples (#9856) # Which Problems Are Solved There were some misunderstandings on how different points would be needed to be applied into existing API definitions. # How the Problems Are Solved - Added structure to the API design - Added points to context information in requests and responses - Added examples to responses with context information - Corrected available pagination messages - Added pagination and filter examples # Additional Changes None # Additional Context None --- API_DESIGN.md | 114 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 15 deletions(-) diff --git a/API_DESIGN.md b/API_DESIGN.md index ea37df5a24..ac7c4a01e0 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -73,6 +73,8 @@ For example, use `organization_id` instead of **org_id** or **resource_owner** f #### Resources and Fields +##### Context information in Requests + When a context is required for creating a resource, the context is added as a field to the resource. For example, when creating a new user, the organization's id is required. The `organization_id` is added as a field to the `CreateUserRequest`. @@ -90,6 +92,65 @@ Only allow providing a context where it is required. The context MUST not be pro For example, when retrieving or updating a user, the `organization_id` is not required, since the user can be determined by the user's id. However, it is possible to provide the `organization_id` as a filter to retrieve a list of users of a specific organization. +##### Context information in Responses + +When the action of creation, update or deletion of a resource was successful, the returned response has to include the time of the operation and the generated identifiers. +This is achieved through the addition of a timestamp attribute with the operation as a prefix, and the generated information as separate attributes. + +```protobuf +message SetExecutionResponse { + // The timestamp of the execution set. + google.protobuf.Timestamp set_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message CreateTargetResponse { + // The unique identifier of the newly created target. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the target creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // Key used to sign and check payload sent to the target. + string signing_key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"98KmsU67\"" + } + ]; +} + +message UpdateProjectGrantResponse { + // The timestamp of the change of the project grant. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeleteProjectGrantResponse { + // The timestamp of the deletion of the project grant. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might be empty. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} +``` + +##### Global messages + Prevent the creation of global messages that are used in multiple resources unless they always follow the same pattern. Use dedicated fields as described above or create a separate message for the specific context, that is only used in the boundary of the same resource. For example, settings might be set as a default on the instance level, but might be overridden on the organization level. @@ -99,6 +160,8 @@ The same applies to messages that are returned by multiple resources. For example, information about the `User` might be different when managing the user resource itself than when it's returned as part of an authorization or a manager role, where only limited information is needed. +##### Re-using messages + Prevent reusing messages for the creation and the retrieval of a resource. Returning messages might contain additional information that is not required or even not available for the creation of the resource. What might sound obvious when designing the CreateUserRequest for example, where only an `organization_id` but not the @@ -190,33 +253,54 @@ In case the permission cannot be checked by the API itself, but all requests nee }; ``` -## Pagination +## Listing resources The API uses pagination for listing resources. The client can specify a limit and an offset to retrieve a subset of the resources. Additionally, the client can specify sorting options to sort the resources by a specific field. -Most listing methods SHOULD provide use the `ListQuery` message to allow the client to specify the limit, offset, and sorting options. -```protobuf +### Pagination -// ListQuery is a general query object for lists to allow pagination and sorting. -message ListQuery { - uint64 offset = 1; - // limit is the maximum amount of objects returned. The default is set to 100 - // with a maximum of 1000 in the runtime configuration. - // If the limit exceeds the maximum configured ZITADEL will throw an error. - // If no limit is present the default is taken. - uint32 limit = 2; - // Asc is the sorting order. If true the list is sorted ascending, if false - // the list is sorted descending. The default is descending. - bool asc = 3; +Most listing methods SHOULD use the `PaginationRequest` message to allow the client to specify the limit, offset, and sorting options. +```protobuf +message ListTargetsRequest { + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional TargetFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"TARGET_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Define the criteria to query for. + repeated TargetSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"sortingColumn\":\"TARGET_FIELD_NAME_CREATION_DATE\",\"filters\":[{\"targetNameFilter\":{\"targetName\":\"ip_allow_list\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"inTargetIdsFilter\":{\"targetIds\":[\"69629023906488334\",\"69622366012355662\"]}}]}"; + }; } ``` -On the corresponding responses the `ListDetails` can be used to return the total count of the resources + +On the corresponding responses the `PaginationResponse` can be used to return the total count of the resources and allow the user to handle their offset and limit accordingly. The API MUST enforce a reasonable maximum limit for the number of resources that can be retrieved and returned in a single request. The default limit is set to 100 and the maximum limit is set to 1000. If the client requests a limit that exceeds the maximum limit, an error is returned. +### Filter method + +All filters in List operations SHOULD provide a method if not already specified by the filters name. +```protobuf +message TargetNameFilter { + // Defines the name of the target to query for. + string target_name = 1 [ + (validate.rules).string = {max_len: 200} + ]; + // Defines which text comparison method used for the name query. + zitadel.filter.v2beta.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} +``` + ## Error Handling The API returns machine-readable errors in the response body. This includes a status code, an error code and possibly From 898366c537f59d2bfeff5dec740ee2ccba916ce7 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 7 May 2025 15:24:24 +0200 Subject: [PATCH 041/123] fix: allow user self deletion (#9828) # Which Problems Are Solved Currently, users can't delete themselves using the V2 RemoveUser API because of the redunant API middleware permission check. On main, using a machine user PAT to delete the same machine user: ```bash grpcurl -plaintext -H "Authorization: Bearer ${ZITADEL_ACCESS_TOKEN}" -d '{"userId": "318838604669387137"}' localhost:8080 zitadel.user.v2.UserService.DeleteUser ERROR: Code: NotFound Message: membership not found (AUTHZ-cdgFk) Details: 1) { "@type": "type.googleapis.com/zitadel.v1.ErrorDetail", "id": "AUTHZ-cdgFk", "message": "membership not found" } ``` Same on this PRs branch: ```bash grpcurl -plaintext -H "Authorization: Bearer ${ZITADEL_ACCESS_TOKEN}" -d '{"userId": "318838604669387137"}' localhost:8080 zitadel.user.v2.UserService.DeleteUser { "details": { "sequence": "3", "changeDate": "2025-05-06T13:44:54.349048Z", "resourceOwner": "318838541083804033" } } ``` Repeated call ```bash grpcurl -plaintext -H "Authorization: Bearer ${ZITADEL_ACCESS_TOKEN}" -d '{"userId": "318838604669387137"}' localhost:8080 zitadel.user.v2.UserService.DeleteUser ERROR: Code: Unauthenticated Message: Errors.Token.Invalid (AUTH-7fs1e) Details: 1) { "@type": "type.googleapis.com/zitadel.v1.ErrorDetail", "id": "AUTH-7fs1e", "message": "Errors.Token.Invalid" } ``` # How the Problems Are Solved The middleware permission check is disabled and the domain.PermissionCheck is used exclusively. # Additional Changes A new type command.PermissionCheck allows to optionally accept a permission check for commands, so APIs with middleware permission checks can omit redundant permission checks by passing nil while APIs without middleware permission checks can pass one to the command. # Additional Context This is a subtask of #9763 --------- Co-authored-by: Livio Spring --- .../user/v2/integration_test/user_test.go | 50 ++-- internal/command/permission_checks.go | 60 ++++ internal/command/permission_checks_test.go | 278 ++++++++++++++++++ internal/command/user_v2.go | 31 -- internal/command/user_v2_invite.go | 6 +- internal/command/user_v2_invite_test.go | 16 +- internal/command/user_v2_test.go | 93 ++++-- proto/zitadel/user/v2/user_service.proto | 2 +- proto/zitadel/user/v2beta/user_service.proto | 2 +- 9 files changed, 459 insertions(+), 79 deletions(-) create mode 100644 internal/command/permission_checks.go create mode 100644 internal/command/permission_checks_test.go diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 70e670bacc..eaf352c094 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -1756,9 +1756,8 @@ func TestServer_DeleteUser(t *testing.T) { projectResp, err := Instance.CreateProject(CTX) require.NoError(t, err) type args struct { - ctx context.Context req *user.DeleteUserRequest - prepare func(request *user.DeleteUserRequest) error + prepare func(*testing.T, *user.DeleteUserRequest) context.Context } tests := []struct { name string @@ -1769,23 +1768,21 @@ func TestServer_DeleteUser(t *testing.T) { { name: "remove, not existing", args: args{ - CTX, &user.DeleteUserRequest{ UserId: "notexisting", }, - func(request *user.DeleteUserRequest) error { return nil }, + func(*testing.T, *user.DeleteUserRequest) context.Context { return CTX }, }, wantErr: true, }, { name: "remove human, ok", args: args{ - ctx: CTX, req: &user.DeleteUserRequest{}, - prepare: func(request *user.DeleteUserRequest) error { + prepare: func(_ *testing.T, request *user.DeleteUserRequest) context.Context { resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() - return err + return CTX }, }, want: &user.DeleteUserResponse{ @@ -1798,12 +1795,11 @@ func TestServer_DeleteUser(t *testing.T) { { name: "remove machine, ok", args: args{ - ctx: CTX, req: &user.DeleteUserRequest{}, - prepare: func(request *user.DeleteUserRequest) error { + prepare: func(_ *testing.T, request *user.DeleteUserRequest) context.Context { resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() - return err + return CTX }, }, want: &user.DeleteUserResponse{ @@ -1816,15 +1812,37 @@ func TestServer_DeleteUser(t *testing.T) { { name: "remove dependencies, ok", args: args{ - ctx: CTX, req: &user.DeleteUserRequest{}, - prepare: func(request *user.DeleteUserRequest) error { + prepare: func(_ *testing.T, request *user.DeleteUserRequest) context.Context { resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateOrgMembership(t, CTX, request.UserId) - return err + return CTX + }, + }, + want: &user.DeleteUserResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "remove self, ok", + args: args{ + req: &user.DeleteUserRequest{}, + prepare: func(t *testing.T, request *user.DeleteUserRequest) context.Context { + removeUser, err := Instance.Client.Mgmt.AddMachineUser(CTX, &mgmt.AddMachineUserRequest{ + UserName: gofakeit.Username(), + Name: gofakeit.Name(), + }) + request.UserId = removeUser.UserId + require.NoError(t, err) + tokenResp, err := Instance.Client.Mgmt.AddPersonalAccessToken(CTX, &mgmt.AddPersonalAccessTokenRequest{UserId: removeUser.UserId}) + require.NoError(t, err) + return integration.WithAuthorizationToken(UserCTX, tokenResp.Token) }, }, want: &user.DeleteUserResponse{ @@ -1837,10 +1855,8 @@ func TestServer_DeleteUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := tt.args.prepare(tt.args.req) - require.NoError(t, err) - - got, err := Client.DeleteUser(tt.args.ctx, tt.args.req) + ctx := tt.args.prepare(t, tt.args.req) + got, err := Client.DeleteUser(ctx, tt.args.req) if tt.wantErr { require.Error(t, err) return diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go new file mode 100644 index 0000000000..bec2e9b7d4 --- /dev/null +++ b/internal/command/permission_checks.go @@ -0,0 +1,60 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/v2/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type PermissionCheck func(resourceOwner, aggregateID string) error + +func (c *Commands) newPermissionCheck(ctx context.Context, permission string, aggregateType eventstore.AggregateType) PermissionCheck { + return func(resourceOwner, aggregateID string) error { + if aggregateID == "" { + return zerrors.ThrowInternal(nil, "COMMAND-ulBlS", "Errors.IDMissing") + } + // For example if a write model didn't query any events, the resource owner is probably empty. + // In this case, we have to query an event on the given aggregate to get the resource owner. + if resourceOwner == "" { + r := NewResourceOwnerModel(authz.GetInstance(ctx).InstanceID(), aggregateType, aggregateID) + err := c.eventstore.FilterToQueryReducer(ctx, r) + if err != nil { + return err + } + resourceOwner = r.resourceOwner + } + if resourceOwner == "" { + return zerrors.ThrowNotFound(nil, "COMMAND-4g3xq", "Errors.NotFound") + } + return c.checkPermission(ctx, permission, resourceOwner, aggregateID) + } +} + +func (c *Commands) checkPermissionOnUser(ctx context.Context, permission string) PermissionCheck { + return func(resourceOwner, aggregateID string) error { + if aggregateID != "" && aggregateID == authz.GetCtxData(ctx).UserID { + return nil + } + return c.newPermissionCheck(ctx, permission, user.AggregateType)(resourceOwner, aggregateID) + } +} + +func (c *Commands) NewPermissionCheckUserWrite(ctx context.Context) PermissionCheck { + return c.checkPermissionOnUser(ctx, domain.PermissionUserWrite) +} + +func (c *Commands) checkPermissionDeleteUser(ctx context.Context, resourceOwner, userID string) error { + return c.checkPermissionOnUser(ctx, domain.PermissionUserDelete)(resourceOwner, userID) +} + +func (c *Commands) checkPermissionUpdateUser(ctx context.Context, resourceOwner, userID string) error { + return c.NewPermissionCheckUserWrite(ctx)(resourceOwner, userID) +} + +func (c *Commands) checkPermissionUpdateUserCredentials(ctx context.Context, resourceOwner, userID string) error { + return c.checkPermissionOnUser(ctx, domain.PermissionUserCredentialWrite)(resourceOwner, userID) +} diff --git a/internal/command/permission_checks_test.go b/internal/command/permission_checks_test.go new file mode 100644 index 0000000000..5c36dc14f9 --- /dev/null +++ b/internal/command/permission_checks_test.go @@ -0,0 +1,278 @@ +package command + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommands_CheckPermission(t *testing.T) { + type fields struct { + eventstore func(*testing.T) *eventstore.Eventstore + domainPermissionCheck func(*testing.T) domain.PermissionCheck + } + type args struct { + ctx context.Context + permission string + aggregateType eventstore.AggregateType + resourceOwner, aggregateID string + } + type want struct { + err func(error) bool + } + ctx := context.Background() + filterErr := errors.New("filter error") + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "resource owner is given, no query", + fields: fields{ + domainPermissionCheck: mockDomainPermissionCheck( + ctx, + "permission", + "resourceOwner", + "aggregateID"), + }, + args: args{ + ctx: ctx, + permission: "permission", + resourceOwner: "resourceOwner", + aggregateID: "aggregateID", + }, + }, + { + name: "resource owner is empty, query for resource owner", + fields: fields{ + eventstore: expectEventstore( + expectFilter(&repository.Event{ + AggregateID: "aggregateID", + ResourceOwner: sql.NullString{String: "resourceOwner"}, + }), + ), + domainPermissionCheck: mockDomainPermissionCheck(ctx, "permission", "resourceOwner", "aggregateID"), + }, + args: args{ + ctx: ctx, + permission: "permission", + resourceOwner: "", + aggregateID: "aggregateID", + }, + }, + { + name: "resource owner is empty, query for resource owner, error", + fields: fields{ + eventstore: expectEventstore( + expectFilterError(filterErr), + ), + }, + args: args{ + ctx: ctx, + permission: "permission", + resourceOwner: "", + aggregateID: "aggregateID", + }, + want: want{ + err: func(err error) bool { + return errors.Is(err, filterErr) + }, + }, + }, + { + name: "resource owner is empty, query for resource owner, no events", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: ctx, + permission: "permission", + resourceOwner: "", + aggregateID: "aggregateID", + }, + want: want{ + err: zerrors.IsNotFound, + }, + }, + { + name: "no aggregateID, internal error", + args: args{ + ctx: ctx, + }, + want: want{ + err: zerrors.IsInternal, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + checkPermission: func(ctx context.Context, permission, orgID, resourceID string) (err error) { + assert.Failf(t, "Domain permission check should not be called", "Called c.checkPermission(%v,%v,%v,%v)", ctx, permission, orgID, resourceID) + return nil + }, + eventstore: expectEventstore()(t), + } + if tt.fields.domainPermissionCheck != nil { + c.checkPermission = tt.fields.domainPermissionCheck(t) + } + if tt.fields.eventstore != nil { + c.eventstore = tt.fields.eventstore(t) + } + err := c.newPermissionCheck(tt.args.ctx, tt.args.permission, tt.args.aggregateType)(tt.args.resourceOwner, tt.args.aggregateID) + if tt.want.err != nil { + assert.True(t, tt.want.err(err)) + } + }) + } +} + +func TestCommands_CheckPermissionUserWrite(t *testing.T) { + type fields struct { + domainPermissionCheck func(*testing.T) domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner, aggregateID string + } + type want struct { + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "self, no permission check", + args: args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{ + UserID: "aggregateID", + }), + resourceOwner: "resourceOwner", + aggregateID: "aggregateID", + }, + }, + { + name: "not self, permission check", + fields: fields{ + domainPermissionCheck: mockDomainPermissionCheck( + context.Background(), + "user.write", + "resourceOwner", + "foreignAggregateID"), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "resourceOwner", + aggregateID: "foreignAggregateID", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + checkPermission: func(ctx context.Context, permission, orgID, resourceID string) (err error) { + assert.Failf(t, "Domain permission check should not be called", "Called c.checkPermission(%v,%v,%v,%v)", ctx, permission, orgID, resourceID) + return nil + }, + } + if tt.fields.domainPermissionCheck != nil { + c.checkPermission = tt.fields.domainPermissionCheck(t) + } + err := c.NewPermissionCheckUserWrite(tt.args.ctx)(tt.args.resourceOwner, tt.args.aggregateID) + if tt.want.err != nil { + assert.True(t, tt.want.err(err)) + } + }) + } +} + +func TestCommands_CheckPermissionUserDelete(t *testing.T) { + type fields struct { + domainPermissionCheck func(*testing.T) domain.PermissionCheck + } + type args struct { + ctx context.Context + resourceOwner, aggregateID string + } + type want struct { + err func(error) bool + } + userCtx := authz.SetCtxData(context.Background(), authz.CtxData{ + UserID: "aggregateID", + }) + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "self, no permission check", + args: args{ + ctx: userCtx, + resourceOwner: "resourceOwner", + aggregateID: "aggregateID", + }, + }, + { + name: "not self, permission check", + fields: fields{ + domainPermissionCheck: mockDomainPermissionCheck( + context.Background(), + "user.delete", + "resourceOwner", + "foreignAggregateID"), + }, + args: args{ + ctx: context.Background(), + resourceOwner: "resourceOwner", + aggregateID: "foreignAggregateID", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Commands{ + checkPermission: func(ctx context.Context, permission, orgID, resourceID string) (err error) { + assert.Failf(t, "Domain permission check should not be called", "Called c.checkPermission(%v,%v,%v,%v)", ctx, permission, orgID, resourceID) + return nil + }, + } + if tt.fields.domainPermissionCheck != nil { + c.checkPermission = tt.fields.domainPermissionCheck(t) + } + err := c.checkPermissionDeleteUser(tt.args.ctx, tt.args.resourceOwner, tt.args.aggregateID) + if tt.want.err != nil { + assert.True(t, tt.want.err(err)) + } + }) + } +} + +func mockDomainPermissionCheck(expectCtx context.Context, expectPermission, expectResourceOwner, expectResourceID string) func(t *testing.T) domain.PermissionCheck { + return func(t *testing.T) domain.PermissionCheck { + return func(ctx context.Context, permission, orgID, resourceID string) (err error) { + assert.Equal(t, expectCtx, ctx) + assert.Equal(t, expectPermission, permission) + assert.Equal(t, expectResourceOwner, orgID) + assert.Equal(t, expectResourceID, resourceID) + return nil + } + } +} diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index 00ca85aaf4..5f8e8d6ff5 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -5,7 +5,6 @@ import ( "github.com/zitadel/logging" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/user" @@ -117,36 +116,6 @@ func (c *Commands) ReactivateUserV2(ctx context.Context, userID string) (*domain return writeModelToObjectDetails(&existingHuman.WriteModel), nil } -func (c *Commands) checkPermissionUpdateUser(ctx context.Context, resourceOwner, userID string) error { - if userID != "" && userID == authz.GetCtxData(ctx).UserID { - return nil - } - if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, userID); err != nil { - return err - } - return nil -} - -func (c *Commands) checkPermissionUpdateUserCredentials(ctx context.Context, resourceOwner, userID string) error { - if userID != "" && userID == authz.GetCtxData(ctx).UserID { - return nil - } - if err := c.checkPermission(ctx, domain.PermissionUserCredentialWrite, resourceOwner, userID); err != nil { - return err - } - return nil -} - -func (c *Commands) checkPermissionDeleteUser(ctx context.Context, resourceOwner, userID string) error { - if userID != "" && userID == authz.GetCtxData(ctx).UserID { - return nil - } - if err := c.checkPermission(ctx, domain.PermissionUserDelete, resourceOwner, userID); err != nil { - return err - } - return nil -} - func (c *Commands) userStateWriteModel(ctx context.Context, userID string) (writeModel *UserV2WriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/command/user_v2_invite.go b/internal/command/user_v2_invite.go index 1325d2e0c9..7760107146 100644 --- a/internal/command/user_v2_invite.go +++ b/internal/command/user_v2_invite.go @@ -73,12 +73,12 @@ func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, if err != nil { return nil, err } - if err := c.checkPermissionUpdateUser(ctx, existingCode.ResourceOwner, userID); err != nil { - return nil, err - } if !existingCode.UserState.Exists() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound") } + if err := c.checkPermissionUpdateUser(ctx, existingCode.ResourceOwner, userID); err != nil { + return nil, err + } if !existingCode.CreationAllowed() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Gg42s", "Errors.User.AlreadyInitialised") } diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go index efb57d86ad..817987e7e4 100644 --- a/internal/command/user_v2_invite_test.go +++ b/internal/command/user_v2_invite_test.go @@ -323,7 +323,21 @@ func TestCommands_ResendInviteCode(t *testing.T) { "missing permission", fields{ eventstore: expectEventstore( - expectFilter(), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("userID", "org1").Aggregate, + "username", "firstName", + "lastName", + "nickName", + "displayName", + language.Afrikaans, + domain.GenderUnspecified, + "email", + false, + ), + ), + ), ), checkPermission: newMockPermissionCheckNotAllowed(), }, diff --git a/internal/command/user_v2_test.go b/internal/command/user_v2_test.go index 3eb9ecd6f7..685ad95253 100644 --- a/internal/command/user_v2_test.go +++ b/internal/command/user_v2_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/org" @@ -1081,13 +1082,14 @@ func TestCommandSide_ReactivateUserV2(t *testing.T) { } func TestCommandSide_RemoveUserV2(t *testing.T) { + ctxUserID := "ctxUserID" + ctx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: ctxUserID}) type fields struct { eventstore func(*testing.T) *eventstore.Eventstore checkPermission domain.PermissionCheck } type ( args struct { - ctx context.Context userID string cascadingMemberships []*CascadingMembership grantIDs []string @@ -1110,7 +1112,6 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), userID: "", }, res: res{ @@ -1128,7 +1129,6 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), userID: "user1", }, res: res{ @@ -1143,7 +1143,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { eventstore: expectEventstore( expectFilter( eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), + user.NewHumanAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", "firstname", @@ -1157,7 +1157,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), ), eventFromEventPusher( - user.NewUserRemovedEvent(context.Background(), + user.NewUserRemovedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", nil, @@ -1169,7 +1169,6 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), userID: "user1", }, res: res{ @@ -1184,7 +1183,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { eventstore: expectEventstore( expectFilter( eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), + user.NewHumanAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", "firstname", @@ -1200,7 +1199,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), expectFilter( eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), + org.NewDomainPolicyAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, true, true, @@ -1209,7 +1208,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), ), expectPush( - user.NewUserRemovedEvent(context.Background(), + user.NewUserRemovedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", nil, @@ -1220,7 +1219,6 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), userID: "user1", }, res: res{ @@ -1235,7 +1233,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { eventstore: expectEventstore( expectFilter( eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), + user.NewHumanAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", "firstname", @@ -1249,7 +1247,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), ), eventFromEventPusher( - user.NewHumanInitializedCheckSucceededEvent(context.Background(), + user.NewHumanInitializedCheckSucceededEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, ), ), @@ -1258,13 +1256,10 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { checkPermission: newMockPermissionCheckNotAllowed(), }, args: args{ - ctx: context.Background(), userID: "user1", }, res: res{ - err: func(err error) bool { - return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) - }, + err: zerrors.IsPermissionDenied, }, }, { @@ -1273,7 +1268,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { eventstore: expectEventstore( expectFilter( eventFromEventPusher( - user.NewMachineAddedEvent(context.Background(), + user.NewMachineAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", "name", @@ -1283,7 +1278,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), ), eventFromEventPusher( - user.NewUserRemovedEvent(context.Background(), + user.NewUserRemovedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", nil, @@ -1292,10 +1287,8 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), ), ), - checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), userID: "user1", }, res: res{ @@ -1310,7 +1303,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { eventstore: expectEventstore( expectFilter( eventFromEventPusher( - user.NewMachineAddedEvent(context.Background(), + user.NewMachineAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", "name", @@ -1322,7 +1315,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), expectFilter( eventFromEventPusher( - org.NewDomainPolicyAddedEvent(context.Background(), + org.NewDomainPolicyAddedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, true, true, @@ -1331,7 +1324,7 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { ), ), expectPush( - user.NewUserRemovedEvent(context.Background(), + user.NewUserRemovedEvent(ctx, &user.NewAggregate("user1", "org1").Aggregate, "username", nil, @@ -1342,7 +1335,6 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), userID: "user1", }, res: res{ @@ -1351,6 +1343,56 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { }, }, }, + { + name: "remove self, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(ctx, + &user.NewAggregate(ctxUserID, "org1").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(ctx, + &user.NewAggregate(ctxUserID, "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewUserRemovedEvent(ctx, + &user.NewAggregate(ctxUserID, "org1").Aggregate, + "username", + nil, + true, + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + userID: ctxUserID, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1358,7 +1400,8 @@ func TestCommandSide_RemoveUserV2(t *testing.T) { eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveUserV2(tt.args.ctx, tt.args.userID, "", tt.args.cascadingMemberships, tt.args.grantIDs...) + + got, err := r.RemoveUserV2(ctx, tt.args.userID, "", tt.args.cascadingMemberships, tt.args.grantIDs...) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 00cb352f70..15bc2d7775 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -539,7 +539,7 @@ service UserService { option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { - permission: "user.delete" + permission: "authenticated" } }; diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index 03bc36220e..f877252f51 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -563,7 +563,7 @@ service UserService { option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { - permission: "user.delete" + permission: "authenticated" } }; From c6aa6385b640f64d55e9ac273de4add22c99e2ba Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 7 May 2025 15:59:02 +0200 Subject: [PATCH 042/123] docs: add invalid information to member requests (#9858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Misleading information on member endpoint requests. # How the Problems Are Solved Add comment to member endpoint requests that the request is invalid if no roles are provided. # Additional Changes None # Additional Context Closes #9415 Co-authored-by: Fabienne Bühler --- proto/zitadel/management.proto | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index e69e331f87..84c7823009 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -9209,7 +9209,7 @@ message AddOrgMemberRequest { repeated string roles = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"ORG_OWNER\"]"; - description: "If no roles are provided the user won't have any rights" + description: "If no roles are provided the user won't have any rights, so the member definition will be regarded as invalid." } ]; } @@ -9222,7 +9222,7 @@ message UpdateOrgMemberRequest { repeated string roles = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"IAM_OWNER\"]"; - description: "If no roles are provided the user won't have any rights" + description: "If no roles are provided the user won't have any rights, so the member definition will be regarded as invalid." } ]; } @@ -9643,7 +9643,7 @@ message AddProjectMemberRequest { repeated string roles = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"PROJECT_OWNER\"]"; - description: "If no roles are provided the user won't have any rights" + description: "If no roles are provided the user won't have any rights, so the member definition will be regarded as invalid." } ]; } @@ -9658,7 +9658,7 @@ message UpdateProjectMemberRequest { repeated string roles = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"PROJECT_OWNER\"]"; - description: "If no roles are provided the user won't have any rights" + description: "If no roles are provided the user won't have any rights, so the member definition will be regarded as invalid." } ]; } @@ -10313,7 +10313,7 @@ message AddProjectGrantMemberRequest { repeated string roles = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"PROJECT_GRANT_OWNER\"]"; - description: "If no roles are provided the user won't have any rights" + description: "If no roles are provided the user won't have any rights, so the member definition will be regarded as invalid." } ]; } @@ -10337,7 +10337,7 @@ message UpdateProjectGrantMemberRequest { repeated string roles = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[\"PROJECT_GRANT_OWNER\"]"; - description: "If no roles are provided the user won't have any rights" + description: "If no roles are provided the user won't have any rights, so the member definition will be regarded as invalid." } ]; } From 21167a4bba8b74720422f2b09ff6753ddb24b67d Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 7 May 2025 16:26:53 +0200 Subject: [PATCH 043/123] fix: add current state for execution handler into setup (#9863) # Which Problems Are Solved The execution handler projection handles all events to check if an execution has to be provided to the worker to execute. In this logic all events would be processed from the beginning which is not necessary. # How the Problems Are Solved Add the current state to the execution handler projection, to avoid processing all existing events. # Additional Changes Add custom configuration to the default, so that the transactions are limited to some events. # Additional Context None --- cmd/defaults.yaml | 3 ++- cmd/setup/38.go | 1 - cmd/setup/55.go | 27 +++++++++++++++++++++++++++ cmd/setup/55.sql | 22 ++++++++++++++++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 4 +++- cmd/start/start.go | 2 +- 7 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 cmd/setup/55.go create mode 100644 cmd/setup/55.sql diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 0d71b4d817..c13d5337b1 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -387,7 +387,8 @@ Projections: org_domain_verified_fields: TransactionDuration: 0s BulkLimit: 2000 - + execution_handler: + BulkLimit: 10 # The Notifications projection is used for preparing the messages (emails and SMS) to be sent to users Notifications: # As notification projections don't result in database statements, retries don't have an effect diff --git a/cmd/setup/38.go b/cmd/setup/38.go index 0a102c9d12..810510bfdd 100644 --- a/cmd/setup/38.go +++ b/cmd/setup/38.go @@ -15,7 +15,6 @@ var ( type BackChannelLogoutNotificationStart struct { dbClient *database.DB - esClient *eventstore.Eventstore } func (mig *BackChannelLogoutNotificationStart) Execute(ctx context.Context, e eventstore.Event) error { diff --git a/cmd/setup/55.go b/cmd/setup/55.go new file mode 100644 index 0000000000..19083515e5 --- /dev/null +++ b/cmd/setup/55.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 55.sql + executionHandlerCurrentState string +) + +type ExecutionHandlerStart struct { + dbClient *database.DB +} + +func (mig *ExecutionHandlerStart) Execute(ctx context.Context, e eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, executionHandlerCurrentState, e.Sequence(), e.CreatedAt(), e.Position()) + return err +} + +func (mig *ExecutionHandlerStart) String() string { + return "55_execution_handler_start" +} diff --git a/cmd/setup/55.sql b/cmd/setup/55.sql new file mode 100644 index 0000000000..60c45d5f94 --- /dev/null +++ b/cmd/setup/55.sql @@ -0,0 +1,22 @@ +INSERT INTO projections.current_states AS cs ( instance_id + , projection_name + , last_updated + , sequence + , event_date + , position + , filter_offset) +SELECT instance_id + , 'projections.execution_handler' + , now() + , $1 + , $2 + , $3 + , 0 +FROM eventstore.events2 AS e +WHERE aggregate_type = 'instance' + AND event_type = 'instance.added' +ON CONFLICT (instance_id, projection_name) DO UPDATE SET last_updated = EXCLUDED.last_updated, + sequence = EXCLUDED.sequence, + event_date = EXCLUDED.event_date, + position = EXCLUDED.position, + filter_offset = EXCLUDED.filter_offset; \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 127f9d7599..5e5c842b14 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -151,6 +151,7 @@ type Steps struct { s52IDPTemplate6LDAP2 *IDPTemplate6LDAP2 s53InitPermittedOrgsFunction *InitPermittedOrgsFunction53 s54InstancePositionIndex *InstancePositionIndex + s55ExecutionHandlerStart *ExecutionHandlerStart } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index eead1980ed..58bc89d2e4 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -198,7 +198,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s35AddPositionToIndexEsWm = &AddPositionToIndexEsWm{dbClient: dbClient} steps.s36FillV2Milestones = &FillV3Milestones{dbClient: dbClient, eventstore: eventstoreClient} steps.s37Apps7OIDConfigsBackChannelLogoutURI = &Apps7OIDConfigsBackChannelLogoutURI{dbClient: dbClient} - steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: dbClient, esClient: eventstoreClient} + steps.s38BackChannelLogoutNotificationStart = &BackChannelLogoutNotificationStart{dbClient: dbClient} steps.s40InitPushFunc = &InitPushFunc{dbClient: dbClient} steps.s42Apps7OIDCConfigsLoginVersion = &Apps7OIDCConfigsLoginVersion{dbClient: dbClient} steps.s43CreateFieldsDomainIndex = &CreateFieldsDomainIndex{dbClient: dbClient} @@ -213,6 +213,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s52IDPTemplate6LDAP2 = &IDPTemplate6LDAP2{dbClient: dbClient} steps.s53InitPermittedOrgsFunction = &InitPermittedOrgsFunction53{dbClient: dbClient} steps.s54InstancePositionIndex = &InstancePositionIndex{dbClient: dbClient} + steps.s55ExecutionHandlerStart = &ExecutionHandlerStart{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -256,6 +257,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s52IDPTemplate6LDAP2, steps.s53InitPermittedOrgsFunction, steps.s54InstancePositionIndex, + steps.s55ExecutionHandlerStart, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/start/start.go b/cmd/start/start.go index e3d84625b4..52d9c6fba8 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -304,7 +304,7 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server execution.Register( ctx, - config.Projections.Customizations["executions"], + config.Projections.Customizations["execution_handler"], config.Executions, queries, eventstoreClient.EventTypes(), From 577bf9c710490ee34d54f7ffc9e3ae79ae556899 Mon Sep 17 00:00:00 2001 From: Maximilian Date: Wed, 7 May 2025 17:58:21 +0200 Subject: [PATCH 044/123] docs(legal): Update to DPA and privacy policy documents (May 2025) (#9566) We are bringing our DPA and privacy policy document in line with our changes to the corporate structure, changes to subprocessors, and new cookie technologies. This PR replaces #3055 which included more changes to terms of service. The changes to terms of service will follow in a second step. --------- Co-authored-by: Florian Forster --- docs/docs/legal/data-processing-agreement.mdx | 274 ++++++++++++++---- docs/docs/legal/policies/privacy-policy.mdx | 231 ++++++++++----- docs/src/components/pii_table.jsx | 106 +++++++ docs/src/components/subprocessors.jsx | 162 ----------- 4 files changed, 490 insertions(+), 283 deletions(-) create mode 100644 docs/src/components/pii_table.jsx delete mode 100644 docs/src/components/subprocessors.jsx diff --git a/docs/docs/legal/data-processing-agreement.mdx b/docs/docs/legal/data-processing-agreement.mdx index 63a393605f..78f14aa730 100644 --- a/docs/docs/legal/data-processing-agreement.mdx +++ b/docs/docs/legal/data-processing-agreement.mdx @@ -1,113 +1,277 @@ --- title: Data Processing Agreement custom_edit_url: null -custom: - created_at: 2022-07-15 - updated_at: 2023-11-16 --- -import PiidTable from './_piid-table.mdx'; -Last updated on November 15, 2023 +Last updated on May 8, 2025 -Within the scope of the [**Framework Agreement**](terms-of-service), the **Processor** (CAOS Ltd., also **ZITADEL**) processes **Personal Data** on behalf of the **Customer** (Responsible Party), collectively the **"Parties"**. +This Data Protection Agreement and its annexes (“**DPA**”) are part of the [Framework Agreement](./terms-of-service) between Zitadel, Inc. and it's affiliates ("**Zitadel**") and the Customer in respect of the provision of certain services, including any applicable statement of work, booking, purchase order (PO) or any agreed upon instructions (the "**Agreement**") and applies where, and to the extent that, Zitadel processes Personal Data as a Processor on behalf of the Customer under the Framework Agreement (each a “**Party**” and together the “**Parties**”). -This Annex to the Agreement governs the Parties' data protection obligations in addition to the provisions of the Agreement. +All capitalized terms not defined in this DPA will have the meanings set forth in the Agreement. +Any privacy or data protection related clauses or agreement previously entered into by Zitadel and the Customer, with regards to the subject matter of this DPA, will be superseded and replaced by this DPA. +No one other than a Party to this DPA, their successors and permitted assignees will have any right to enforce any of its terms. -## Subject matter, duration, nature and purpose of the processing as well as the type of personal data and categories of data subjects +This DPA shall become legally binding upon Customer entering into the Agreement. -This annex reflects the commitment of both parties to abide by the applicable data protection laws for the processing of Personal Data for the purpose of Processor's execution of the Framework Agreement. +## Definitions -The duration of the Processing shall correspond to the duration of the Agreement, unless otherwise provided for in this Annex or unless individual provisions obviously result in obligations going beyond this. +"**Applicable Data Protection Law**" means all worldwide data protection and privacy laws and regulations applicable to the Personal Data, including, where applicable, EU/UK Data Protection Law and US Data Protection Laws (in each case, as amended, adopted, or superseded from time to time). -In particular, the following Personal Data are part of the processing: - +“**Controller**,” “**collecting**,” “**processor**,” and “**processing**,” shall have the meanings given to them under Applicable Data Protection Law. -## Scope and responsibility +“**Business**,” “**service provider**,” “**contractor**,” “**selling**,” “**sharing**” and “**third party**” shall have the meanings given to them under applicable US Data Protection Laws. -Under this Agreement, the Processor shall process Personal Data on behalf of the Customer. +"**Customer Data**" means information, data and other content, in any form or medium, that is submitted, posted or otherwise transmitted by or on behalf of the Customer through the Zitadel Cloud or Services. For the avoidance of doubt, Customer Data includes Customer Personal Data. -This Annex applies to all processing of Customer's data (including data of the users of Customer's organization) with reference to persons ("**Personal Data**") which is related to the Agreement and which is carried out by the Processor, its employees or agents. +“**Customer Personal Data**” means, in any form or medium, all Personal Data that is processed by Zitadel or its sub-processors on behalf of Customer in connection with the Agreement. -The Customer shall be responsible for compliance with the statutory provisions of the data protection laws, in particular for the lawfulness of the transfer of data to the Processor as well as for the lawfulness of the data processing. +“**EU/UK Data Protection Law**” means: (i) Regulation 2016/679 of the European Parliament and of the Council on the protection of natural persons with regard to the processing of Personal Data and on the free movement of such data, also known as the General Data Protection Regulation (“**GDPR**”); (ii) the GDPR as saved into United Kingdom law by virtue of section 3 of the United Kingdom’s European Union (Withdrawal) Act 2018 (“**UK GDPR**”); (iii) the EU e-Privacy Directive (Directive 2002/58/EC); (iv) the Swiss Federal Act on Data Protection of 2020 and its Ordinance (“**Swiss FADP**”) and (v) any and all applicable national data protection laws and regulatory requirements made under, pursuant to or that apply in conjunction with any of (i), (ii) or (iii); in each case as may be amended or superseded from time to time. -The Processor is responsible for taking appropriate technical and organizational protection measures so that its processing complies with the legal requirements and ensures the protection of the rights of the Data Subjects. +“**Personal Data**” shall have the meaning given to it, or to the terms “personally identifiable information” and “personal information” under applicable Data Protection Law, but shall include, at a minimum, any information related to an identified or identifiable natural person. + +“**Restricted Transfer**” means: (i) where the GDPR applies, a transfer of Personal Data from the EEA to a country outside of the EEA which is not subject to an adequacy determination by the European Commission; and (ii) where UK-GDPR applies, a transfer of Personal Data from the United Kingdom to any other country which is not subject to adequacy regulations pursuant to Section 17A of the United Kingdom Data Protection Act 2018, in each case whether such transfer is direct or via onward transfer. + +“**Security Incident**” means any unauthorized or unlawful breach of security leading to, or reasonably believed to have led to, the accidental or unlawful destruction, loss, or alteration of, or unauthorized disclosure or access to, Personal Data transmitted, stored or otherwise processed by Zitadel under or in connection with the Agreement. + +“**Standard Contractual Clauses**” or “**SCCs**” means the contractual clauses annexed to the European Commission’s Implementing Decision 2021/914 of 4 June 2021 on standard contractual clauses for the transfer of personal data to third countries pursuant to Regulation (EU) 2016/679 of the European Parliament and of the Council. + +“**sub-processor**” means any third-party processor engaged by Zitadel to process Customer Data (but shall not include Zitadel employees, contractors or consultants). + +“**UK Addendum**” means the International Data Transfer Addendum (version B1.0) issued by the Information Commissioner’s Office under S119(A) of the UK Data Protection Act 2018, as updated or amended from time to time. + +“**US Data Protection Laws**” means any relevant U.S. federal and state privacy laws (and any implementing regulations and amendment thereto) effective as of the date of this DPA and that applies to the processing of Customer Personal Data under the Agreement, which may include, depending on the circumstances and without limitation, (i) the California Consumer Privacy Act (Cal. Civ. Code §§ 1798.100 et seq.), as amended by the California Privacy Rights Act of 2020 along with its implementing regulations (“**CCPA**”), (ii) the Colorado Privacy Act (Colo. Rev. Stat. §§ 6-1-1301 et seq.) (CPA), (iii) Connecticut’s Data Privacy Act (CTDPA), (iv) the Utah Consumer Privacy Act (Utah Code Ann. §§ 13-61-101 et seq.) (UCPA) and (v) the Virginia Consumer Data Protection Act VA Code Ann. §§ 59.1-575 et seq. (VCDPA). + +## Processing of Personal Data + +This DPA applies where and only to the extent that Zitadel processes Customer Personal Data in connection with the provision of the Services under the Agreement involving the processing of Personal Data protected by Applicable Data Protection Law. +This DPA reflects the commitment of both Parties to abide by Applicable Data Protection Law for the processing of Personal Data by Zitadel as a processor for the purpose of the Zitadel's provision of the Services and its execution of the Agreement. + +This DPA will become effective on the date the Agreement enters into effect and will remain in force for the term of the Agreement, unless otherwise provided for in this DPA or unless individual provisions obviously result in obligations going beyond this. +For the avoidance of doubt, the terms of the Framework Agreement will continue in full force and effect; however, to the extent any term in any Agreement regarding either Party’s obligations with respect to Customer Data is less restrictive than or is inconsistent with this DPA, the terms of this DPA shall supersede and control. + +The Parties acknowledge that the following Customer Data will be processed as part of the Services: + +import { PiiTable } from "../../src/components/pii_table"; + + + +## Scope + +Under this Agreement, Zitadel shall process Customer Personal Data to perform its obligations under the Agreement and and strictly in accordance with the documented instructions of Customer (the “**Permitted Purpose**”), except where otherwise required by law(s) that are not incompatible with Applicable Data Protection Law. + +The Parties acknowledge and agree that for the purposes of this DPA, the Customer is the controller and appoints Zitadel as a processor to process the Customer Personal Data. +To the extent that the Parties are subject to the California Consumer Privacy Act (CCPA), the Customer is the business whereas Zitadel is a service provider to the Customer. +Each Party shall comply with the obligations that apply to it under Applicable Data Protection Law. + +Each Party shall comply with its own obligations under Applicable Data Protection Law in respect of any Customer Personal Data processed under the Agreement. + +## Customer's Responsibilities + +The Customer’s instructions to Zitadel shall comply with Applicable Data Protection Law. +The Customer will have sole responsibility for the accuracy, quality and legality of the Customer Data, the means by which the Customer acquired the Customer Data, and the Customer's permissions to process the Customer Data pursuant to this DPA. + +As required under Applicable Data Protection Law, the Customer will provide all necessary notices to data subjects and secure the applicable lawful grounds for processing Data under the DPA, including where applicable, all necessary permissions and consents from them. To the extent required under Applicable Data Protection Law, the Customer will receive and document the appropriate consent from the data subject(s). + +The Customer represents and warrants that (i) it complies with Applicable Data Protection Law as relevant to the lawful processing by Zitadel of Customer Personal Data for the purposes contemplated by this DPA and the Agreement; and (ii) to the knowledge of the Customer, the processing of Customer Personal Data by Zitadel in accordance with the Customer’s instructions will not cause Zitadel to be in breach of any Applicable Data Protection Law. + +The Customer shall not disclose any special categories of Personal Data or sensitive personal information (as these terms are defined under Applicable Data Protection Law) to Zitadel for processing. ## Obligations of the processor -### Bound by directions +### Bound by the Customer's directions and instructions -The Processor processes personal data in accordance with its privacy policy (cf. [Privacy Policy](/legal/policies/privacy-policy)) and on the documented directions of the Customer. The initial direction result from the Agreement. Subsequent instructions shall be given either in writing, whereby e-mail shall suffice, or orally with immediate written confirmation. +Customer hereby instructs Zitadel to process Customer Data for the Permitted Purpose. -If the Processor is of the opinion that a direction of the Customer violates the Agreement, the GDPR or other data protection provisions of the EU, EU Member States or Switzerland, it shall inform the Customer thereof and shall be entitled to suspend the Processing until the instruction is withdrawn or confirmed. +Zitadel processes Personal Data in accordance with its privacy policy (cf. [Privacy Policy](./policies/privacy-policy)) and upon the documented directions of the Customer (which includes the Agreement). +Subsequent instructions shall be given either in writing, whereby e-mail shall suffice, or orally with immediate written confirmation. -### Obligation of the processing persons to confidentiality +Zitadel shall promptly inform Customer if it becomes aware that such processing instructions infringe Applicable Data Protection Law (but without obligation to actively monitor compliance with Applicable Data Protection Law). +In such case, Zitadel shall be entitled to suspend the processing until the infringing instruction is withdrawn or confirmed. -The Processor shall ensure that the persons authorized to process the Personal Data have committed themselves to confidentiality, unless they are already subject to an appropriate statutory duty of confidentiality. +### Confidentiality obligations + +Zitadel shall ensure that any person that it authorizes to process Customer Data (including Zitadel’s staff, agents and sub-processors) (an “Authorized Person”) shall be subject to a strict duty of confidentiality (whether a contractual duty or a statutory duty) and shall not permit any person to process the Customer Data that is not under such a duty of confidentiality. +Zitadel shall ensure that all Authorised Persons process the Customer Data only as necessary for the Permitted Purpose. ### Technical and organizational measures -The Processor has taken appropriate technical and organizational security measures, maintains them for the duration of the Processing and updates them on an ongoing basis in accordance with the current state of technology. - -The technical and organizational security measures are described in more detail in the [annex](#annex-regarding-security-measures) to this appendix. +Zitadel shall implement appropriate technical and organizational measures to protect the Customer Data from a Security Incident, as described in Annex II to this DPA. +Such measures shall comply with all Applicable Data Protection Law and shall further have regard to the state of the art, the costs of implementation and the nature, scope, context and purposes of processing as well as the risk of varying likelihood and severity for the rights and freedoms of natural persons. +The Customer acknowledges that such measures are subject to technical progress and development and that Zitadel may update or modify such measures from time to time, provided that such updates and modifications do not degrade or diminish overall security of the Customer Data, or of the Services under the Agreement. ### Involvement of subcontracted processors -A current and complete [list of involved and approved sub-processors](./subprocessors) can be found in our legal section. +Customer agrees that Zitadel may engage sub-processors to process Customer Data on Customer’s behalf. A current and complete [list of involved and approved sub-processors](https://zitadel.com/trust) can be found on our [Trust Center](https://zitadel.com/trust) (as may be updated from time to time in accordance with this DPA). -The Processor is entitled to involve additional sub-processors. -In this case, the Processor shall inform the Responsible Party about any intended change regarding sub-processors and update the list of involved an approved sub-processors. -The Customer has the right to object to such changes. -If the Parties are unable to reach a mutual agreement within 30 days of receipt of the objection by the Processor, the Customer may terminate the Agreement extraordinarily. +Zitadel will notify Customer by updating the list of sub-processors and, if Customer has subscribed to notices, via email. +If, within five (5) calendar days after such notice, Customer notifies Zitadel in writing that Customer objects to Zitadel's appointment of a new sub-processor based on reasonable data protection concerns, the parties will discuss such concerns in good faith with a view to achieving a commercially reasonable resolution. +If the parties are not able to mutually agree to a resolution of such concerns, Customer, as its sole and exclusive remedy, may terminate the Agreement for convenience with no refunds and Customer will remain liable to pay any committed fees in an order form, order, statement of work or other similar ordering document. -The Processor obligates itself to impose on all sub-processors, by means of a contract (or in another appropriate manner), the same data protection obligations as are imposed on it by this Annex. -In particular, sufficient guarantees shall be provided that the appropriate technical and organizational measures are implemented in such a way that the processing by the sub-processor is carried out in accordance with the legal requirements. +Zitadel shall inform the Customer if it adds or replaces any sub-processor at least fifteen (15) days prior to any such change (including details of the processing it performs or will perform). +The Customer may object in writing to Zitadel’s engagement of a new sub-processor on reasonable grounds relating to the protection of Customer Personal Data by notifying Zitadel promptly in writing within fifteen (15) calendar days of receipt of Zitadel’s notice. +In such case, the parties shall discuss Customer’s concerns in good faith with a view to achieving a commercially reasonable resolution. +If such objection right is not exercised by Customer, silence shall be deemed to constitute an approval of the relevant sub-processor engagement. -Our websites and services may involve processing by third-party sub-processors with country of registration outside of Switzerland or the EU/EAA. -In these cases, we only transfer personal data after we have implemented the legally required measures for this, such as concluding standard contractual clauses on data protection or obtaining the consent of the data subjects. If interested, the documentation on these measures can be obtained from the contact person mentioned above. -The country of registration of a sub-processor may be different from the hosting location of the data. Please refer to the [list of involved and approved sub-processors](./subprocessors) for more details. +Where Zitadel appoints a sub-processor, Zitadel shall: (i) enter into an agreement with each sub-processor containing data protection terms that provide at least the same level of protection for Customer Data as those contained in this DPA, to the extent applicable to the nature of the services provided by such sub-processor; and (ii) remain responsible to the Customer for Zitadel’s sub-processors’ failure to perform their obligations with respect to the processing of Customer Data. -If the sub-processor fails to comply with its data protection obligations, the processor shall be liable to the customer for this as for its own conduct. +Taking into account the safeguards set forth in this DPA, Customer Data may be processed outside of Switzerland or the EU/EAA, such as in the United States or any country in which Zitadel or is sub-processors operate. Our [list of involved and approved sub-processors](https://zitadel.com/trust) provides additional details. ### Assistance in responding to requests -The Processor shall support the Customer as far as possible with suitable technical and organizational measures in fulfilling its obligation to respond to requests to exercise the data subject's rights (**"Data Subject Request"**). -The Processor will promptly notify the Customer if it receives a Data Subject Request. -The Processor will not respond to a Data Subject Request, provided that the Customer agrees the Processor may at its discretion respond to confirm that such request relates to the Customer. -The Customer acknowledges and agrees that the Services include features which will allow the Customer to manage Data Subject Requests directly through the Services without additional assistance from the Processor. -If the Customer does not have the ability to address a Data Subject Request, the Processor will, upon the Customer’s written request, provide reasonable assistance to facilitate the Customer’s response to the Data Subject Request to the extent such assistance is consistent with applicable law; provided that the Customer will be responsible for paying for any costs incurred or fees charged by the Processor for providing such assistance. +Zitadel shall provide all reasonable and timely assistance (which may include by appropriate technical and organizational measures) to the Customer to enable the Customer to respond to: (i) any request from a data subject to exercise any of their rights under Applicable Data Protection Law ("**Data Subject Request**"); and (ii) any other correspondence, enquiry or complaint received from a data subject, regulator or other third party in connection with the processing of Customer Personal Data. -The Processor, unless prohibited from doing so by applicable law, will promptly notify the Customer of any requests from a regulator or any other authority in relation to Personal Data that is being processed on behalf of the Customer, given that request resulted in disclosure of Personal Data to the regulator or any other authority. +In the event that any such request, correspondence, enquiry or complaint is made directly to Zitadel, Zitadel shall promptly inform the Customer providing full details of the same. +Zitadel will not respond to a Data Subject Request, however the Customer acknowledges and agrees that Zitadel may at its discretion respond to confirm that such request relates to the Customer. -### Further support for the customer +The Customer hereby acknowledges and agrees that the Services include features which will allow the Customer to manage Data Subject Requests directly through the Services without additional assistance from the Processor. +If the Customer does not have the ability to address a Data Subject Request, Zitadel will, upon the Customer’s written request, provide reasonable assistance to facilitate the Customer’s response to such Data Subject Request to the extent such assistance is consistent with Applicable Data Protection Law; provided that the Customer will be responsible for paying for any reasonable costs incurred or fees charged by Zitadel for providing such assistance. -The Processor shall, taking into account the nature of the processing and the information available to it, assist the Customer in complying with its obligations in connection with the security of the processing, any notifications of [Security Incidents](#security-incidents), and any data protection impact assessments. +Zitadel, unless prohibited from doing so by applicable law, will promptly notify the Customer of any requests from a regulator, law enforcement authority or any other relevant and competent authority in relation to the Customer Personal Data that is being processed on behalf of the Customer, to the extent that the request may result in the disclosure of Customer Personal Data to such regulator, law enforcement authority or any other relevant and competent authority. + +### Cooperation and support for the Customer + +Zitadel shall provide the Customer with all such reasonable and timely assistance as Customer may require in order to enable it to conduct a data protection impact assessment (or equivalent document) where required by Applicable Data Protection Law, including, if necessary, to assist Customer to consult with its relevant data protection or other regulatory authority. ### Security incidents -The Processor will notify the Customer of any incident, meaning breach of security or other action or inaction leading to the accidental or unlawful destruction, loss, alteration, unauthorised disclosure of, or access to, personal data covered under this (***Security Incident"**) without undue delay, and will promptly provide the Customer with all reasonable information concerning the Security Incident insofar as it affects the Customer. -If possible, the Processor will promptly implement measures proposed in the notification. -Insofar required the Processor will assist the Customer in notifying any applicable regulatory authority. +Upon becoming aware of a Security Incident, Zitadel shall inform Customer without undue delay and provide all such timely information and cooperation as Customer may require for the Customer to fulfil its data breach or cybersecurity incident reporting obligations under (and in accordance with the timescales required by) Applicable Data Protection Law. +Customer shall further take all such measures and actions as are reasonable and necessary to investigate, contain, and remediate or mitigate the effects of the Security Incident, to the extent that the remediation is within Zitadel's control, and shall keep Customer informed of all material developments in connection with the Security Incident. + +Notwithstanding anything to the contrary, Zitadel's notification of or response to a Security Incident under this section will not be construed as an acknowledgment by Zitadel of any fault or liability with respect to such Security Incident. ### Deletion or destruction after termination -Upon Customer's request, the Processor shall delete personal data received after the end of the agreement, unless there is a legal obligation for the Processor to store or further process such data. +Upon termination or expiry of the Agreement, Zitadel shall (at the Customer’s election) destroy or return to the Customer all Customer Data (including all copies of the Customer Data) in its possession or control (including any Customer Data subcontracted to a third party for processing). +This requirement shall not apply to the extent that Zitadel is required by any applicable law to retain some or all Customer Data, in which case Zitadel shall isolate and protect the Customer Data from any further processing except to the extent required by such law until deletion is possible. -### Information and control rights of the customer +### Customer's information and audit rights -The Processor shall provide the Customer with all information necessary to demonstrate compliance with the obligations set forth in this annex or to respond to requests from an applicable supervisory authority, subject to the confidentiality terms in the Framework Agreement. -The Processor shall enable and contribute to audits, including inspections, carried out by the Customer or an auditor appointed by the Customer. +To the extent required under Applicable Data Protection Law and on written request from the Customer, Zitadel shall provide written responses (which may include audit report summaries/extracts) to all reasonable requests for information made by the Customer related to its processing of Customer Personal Data as necessary to confirm Zitadel's compliance with this DPA. +The Customer shall not exercise this right more than once in any twelve (12)-month rolling period, except (i) if and when required by instruction of a competent data protection or other regulatory authority; or (ii) if Zitadel has experienced a Security Incident where Customer was directly impacted. -The procedure to be followed in the event of directions that are presumed to be unlawful is governed by the section [Bound by directions](#bound-by-directions) of this Appendix. +Nothing in this section shall be construed to require Zitadel to document or provide: (i) trade secrets or any proprietary information; (ii) any information that would violate Zitadel’s confidentiality obligations, contractual obligations, or applicable law; or (iii) any information, the disclosure of which could threaten, compromise, or otherwise put at risk the security, confidentiality, or integrity of Zitadel’s infrastructure, networks, systems, algorithms or data. -## Annex regarding security measures +### Service Optimization -The Processor has taken the following organizational and technical security measures to ensure a level of protection of the Personal Data processed that is appropriate to the risk: +Where permitted by Applicable Data Protection Law, Zitadel may process Customer Data: (i) for its internal uses to build or improve the quality of its services; (ii) to detect Security Incidents; and (iii) to protect against fraudulent or illegal activity. + +Zitadel may: (i) compile aggregated and/or de-identified information in connection with the provision of the Services, provided that such information cannot reasonably be used to identify Customer or any data subject to whom Customer Personal Data relates (“Aggregated and/or De-Identified Data”); and (ii) use such Aggregated and/or De-Identified Data for its lawful business purposes in accordance with Applicable Data Protection Law. + +### Data Transfers + +Where either Party intends to transfer Personal Data cross-border and Applicable Data Protection Law requires certain measures to be implemented prior to such transfer, each Party agrees to implement such measures to ensure compliance with Applicable Data Protection Law. + +To the extent that the transfer of Personal Data from Customer to Zitadel involves a transfer of Personal Data outside the European Economic Area (EEA), Switzerland, or the United Kingdom to a jurisdiction which is not subject to an adequacy determination by the European Commission, United Kingdom or Swiss authorities (as applicable) that covers such transfer, then the SCCs are hereby incorporated by reference and form an integral part of the DPA. + +#### EEA Transfers + +To the extent that Customer Personal Data is subject to the GDPR, and the transfer would be a Restricted Transfer, the SCCs apply as follows: + +1) the Customer is the ‘data exporter’ and Zitadel is the ‘data importer’; +2) the Module Two terms (Transfer controller to processor) apply; +3) in Clause 7, the optional docking clause does not apply; +4) in Clause 9, Option 2 (General Authorization) applies and the time period for prior notice of sub-processor changes is set out in this DPA; +5) in Clause 11, the optional language does not apply; +6) in Clause 17, Option 1 applies, and the SCCs are governed by German law; +7) in Clause 18(b), disputes will be resolved before the courts of Hamburg in Germany; +8) in Annex I, the details of the parties and the transfer are set out in the Agreement; +9) in Clause 13(a) and Annex I, the Hamburg data protection authority will act as competent supervisory authority; +10) in Annex II, the description of the technical and organizational security measures is set out in Annex 2 of this DPA or, if not set out therein, the applicable statement of work; and +11) in Annex III, the list of sub-processors is set out at the address [https://zitadel.com/trust](https://zitadel.com/trust) or, if not set out therein, applicable statement of work. + +#### Swiss Transfers + +To the extent that Customer Personal Data is subject to Swiss law, and the transfer would be a Restricted Transfer, the SCCs apply as set out above with the following modifications: + +1) references to ‘Regulation (EU) 2016/679’ are interpreted as references to the Swiss FADP or any successor thereof; +2) references to specific articles of ‘Regulation (EU) 2016/679’ are replaced with the equivalent article or section of the Swiss FADP, +3) references to ‘EU’, ‘Union’ and ‘Member State’ are replaced with ‘Switzerland’, +4) Clause 13(a) and Part C of Annex 2 is not used and the ‘competent supervisory authority’ is the Swiss Federal Data Protection Information Commissioner (“**FDPIC**”) or, if the transfer is subject to both the Swiss FADP and the GDPR, the FDPIC (insofar as the transfer is governed by the Swiss FADP) or the DPC (insofar as the transfer is governed by the GDPR), +5) references to the ‘competent supervisory authority’ and ‘competent courts’ are replaced with the FDPIC and ‘competent Swiss courts’, +6) in Clause 17, the SCCs are governed by the laws of Switzerland, +7) in Clause 18(b), disputes will be resolved before the competent Swiss courts, and +8) the SCCs also protect the data of legal entities until entry into force of the revised Swiss FADP. + +#### UK Transfers + +To the extent that Customer Personal Data is subject to Applicable Data Protection Law of the United Kingdom, and the transfer would be a Restricted Transfer, the SCCs as set out above shall apply as amended by Part 2 of the UK Addendum, and Part 1 of the UK Addendum is deemed completed as follows: + +1) in Table 1, the details of the parties are set out in the Agreement or, if not set out therein, the applicable statement of work; +2) in Table 2, the selected modules and clauses are set out in Section 6.3 of this DPA; +3) in Table 3, the appendix information is set out in the annexes to this DPA or, if not set out therein, the applicable statement of work; and +4) in Table 4, the ‘Exporter’ is selected. + +#### Alternative Transfer Mechanism + +In the event that a court of competent jurisdiction or supervisory authority orders (for whatever reason) that the measures described in this DPA cannot be relied on to lawfully transfer Customer Personal Data, or Zitadel adopts an alternative data transfer mechanism to the mechanisms described in this DPA, including any new version of or successor to the standard contractual clauses (“Alternative Transfer Mechanism”), the Customer agrees to fully co-operate with Zitadel to agree an amendment to this DPA and/or execute such other documents and take such other actions as may be necessary to remedy such non-compliance or give legal effect to such Alternative Transfer Mechanism. + +### Additional Provisions under US Data Protection Laws + +The Parties agree that all Customer Personal Data that is subject to US Data Protection Laws (including the CCPA) is disclosed to Zitadel by the Customer for the Permitted Purpose and its use or sharing by the Customer with Zitadel is necessary to perform such Permitted Purpose. + +Zitadel agrees that it will not: + +1. sell or share any Customer Personal Data to a third party for any purpose other than than for the Permitted Purpose; +2. retain, use, or disclose any Customer Personal Data (i) for any purpose other than for the Permitted Purpose, including for any commercial purpose, or (ii) outside of the direct business relationship between the Parties, except as necessary to perform the Permitted Purpose or as otherwise permitted by US Data Protection Laws; or +3. combine Customer Personal Data received from or on behalf of Customer with Personal Data received from or on behalf of any third party or collected from Zitadel’s own interaction with individuals or data subjects, except to perform a Permitted Purpose in accordance with the CCPA, the Agreement and this DPA. + +The Parties acknowledge that the Customer Personal Data that Customer discloses to Zitadel is provided only for the limited and specified purposes set forth as the Permitted Purpose in the Agreement and this DPA. + +Zitadel shall provide the same level of protection to Customer Personal Data as required by the CCPA and will: (i) assist the Customer in responding to any request from a data subject to exercise rights under US Data Protection Laws; and (ii) immediately notify the Customer if it is not able to meet the requirements under the CCPA. + +The Customer may take such reasonable and appropriate steps as may be necessary (a) to ensure that the Customer Personal Data collected is used in a manner consistent with the business’s obligations under the CCPA; and (b) to stop and remediate any unauthorized use of Customer Personal Data, and (b) to ensure that Customer Personal Data is used in a manner consistent with the CCPA. + +### Miscellaneous + +This DPA shall be governed by and construed in accordance with the governing law and jurisdiction provisions set out in the Agreement, unless required otherwise by Applicable Data Protection Law. + +Any liability owed by one party to the other under this DPA shall be subject to the limitations of liability set forth in the Agreement. + +This DPA shall terminate upon the earlier of (i) the termination or expiry of all Agreement under which Customer Data may be processed, or (ii) the written agreement of the Parties. + +Any notices shall be delivered to a Party in accordance with the notice provisions of the Agreement, unless otherwise specified hereunder. + +## Annex 1: Description of Processing Activities / Transfer + +### List of Parties + +| Data Exporter | Data Importer | +| :---- | :---- | +| Name: The Party identified as the Customer in the Agreement. | Name: The Party identified as Zitadel in the Agreement. | +| Address: As identified in the Agreement. | Address: As identified in the Agreement. | +| Contact Person's Name, position and contact details: As identified in the Agreement. | Contact Person's Name, position and contact details: As identified in the Agreement. | +| Activities relevant to the transfer: See below | Activities relevant to the transfer: See below | +| Role: Controller | Role: Processor | + +### Description of processing / transfer + +| | Description | +| :---- | :---- | +| **Categories of data subjects:** | As described in the section "Processing of Personal Data" of the DPA | +| **Categories of personal data:** | As described in the section "Processing of Personal Data" of the DPA | +| **Sensitive data:** | None. | +| **If sensitive data, the applied restrictions or safeguards** | N/A | +| **Frequency of the transfer:** | Continuous | +| **Nature and subject matter of processing:** | The Services described in the Agreement. | +| **Purpose(s) of the data transfer and further processing:** | As set forth in the Agreement. | +| **Retention period (or, if not possible to determine, the criteria used to determine that period):** | The personal data may be retained until termination or expiry of the DPA. | + +### Competent supervisory authority + +The competent supervisory authority in connection with Customer Personal Data protected by the GDPR, is the Hamburg data protection authority. +If this is not possible, then as otherwise agreed by the parties consistent with the conditions set forth in Clause 13. + +In connection with Customer Personal Data that is protected by UK-GDPR, the competent supervisory authority is the Information Commissioners Office (the "ICO"). + +## Annex 2: Technical and organizational measures + +Zitadel has implemented an information security program, that is designed to protect the confidentiality, integrity and availability of Customer Data. Zitadel's information security program includes the following organizational and technical security measures to ensure a level of protection of the Personal Data processed that is appropriate to the risk: ### Pseudonymization / Encryption The following measures for pseudonymization and encryption exist: -1. All communication is encrypted with TLS >1.2 with PFS +1. All communication is encrypted with TLS >1.2 with PFS 2. Critical data is exclusively stored in encrypted form 3. Storage media that store customer data are always encrypted 4. Passwords are irreversibly stored with a hash function diff --git a/docs/docs/legal/policies/privacy-policy.mdx b/docs/docs/legal/policies/privacy-policy.mdx index 30e213adb0..3dc544f8ae 100644 --- a/docs/docs/legal/policies/privacy-policy.mdx +++ b/docs/docs/legal/policies/privacy-policy.mdx @@ -2,20 +2,42 @@ title: Privacy Policy custom_edit_url: null --- -import PiidTable from '../_piid-table.mdx'; -Last updated on March 07, 2024 +Last updated on 20 March, 2025 -This privacy policy applies to CAOS Ltd., the websites it operates (including zitadel.ch, zitadel.cloud and zitadel.com) and the services and products it provides (including ZITADEL). This privacy policy describes how we process personal data for the provision of this websites and our products. +This privacy policy describes how ZITADEL Inc. and its wholly owned subsidiaries and affiliates (collectively, "**ZITADEL**", “**CAOS**", "**we**" or "**us**") collect, use, disclose and otherwise process your personal data in connection with the management of our business and our relationships with customers, visitors and event attendees. -If any inconsistencies arise between this Privacy Policy and the otherwise applicable contractual terms, framework agreement, or general terms of service, the provisions of this Privacy Policy shall prevail. This privacy policy covers both existing personal data and personal data collected from you in the future. +This privacy policy explains your rights and choices related to the personal data we collect when: -The responsible party for the data processing described in this privacy policy and contact for questions and issues regarding data protection is +* You interact with our websites, including zitadel.com, zitadel.cloud and zitadel.ch as well any other websites that we operate and that link to this privacy policy (our “**Sites**”) -**CAOS AG** +* You visit, interact with, or use any of our offices, events, sales, marketing or other activities; and + +* You use our platform, including ZITADEL and our software, mobile application, and other products and services (the “**Services**”). + +This privacy policy does not cover: + +* **Organizational Use**. When you use our Services on behalf of an organization (your employer), your use is administered and provisioned by your organization under its policies regarding the use and protection of personal data. If you have questions about how your data is being accessed or used by your organization, please refer to your organization's privacy policy and direct your inquiries to your organization's system administrator. + +* **Third Parties**. Our Sites include links to websites and/or applications operated and maintained by third parties (e.g. GitHub, LinkedIn, etc.). This privacy policy does not apply to any products, services, websites, or content that are offered by third parties and/or have their own privacy policy. + +If any inconsistencies arise between this privacy policy and the otherwise applicable contractual terms, framework agreement, or general terms of service, the provisions of this privacy policy shall prevail (where applicable). This privacy policy covers both existing personal data and personal data which may be collected from you in the future. + +ZITADEL determines the purposes for and means of the processing (i.e., we are the data controller) of your personal data as described in this privacy policy, unless expressly specified otherwise. The responsible party for the data processing described in this privacy policy and contact for questions and issues regarding data protection is: + +**Zitadel Inc.** +Data Protection Officer +Four Embarcadero Center, Suite 1400 +San Francisco, CA 94111-4164 +United States of America +[legal@zitadel.com](mailto:legal@zitadel.com) + +**CAOS AG (Affiliate of Zitadel, Inc.)** Data Protection Officer Lerchenfeldstrasse 3 -9014 St. Gallen +9014 St. Gallen +Switzerland +[legal@zitadel.com](mailto:legal@zitadel.com) Switzerland [legal@zitadel.com](mailto:legal@zitadel.com) @@ -41,15 +63,13 @@ This website uses TLS encryption for security reasons and to protect the transmi We process personal data in accordance with Swiss data protection law. In addition, we process - to the extent and insofar as the EU Data Protection Regulation is applicable - personal data in accordance with the following legal bases within the meaning of Art. 6 (1) DSGVO : -- Insofar as we obtain the consent of the data subject for processing operations, Art. 6 (1) a) DSGVO serves as the legal basis. -- When processing personal data for the fulfillment of a contract with the data subject as well as for the implementation of corresponding pre-contractual measures, Art. 6 para. 1 lit. b DSGVO serves as the legal basis. -- To the extent that processing of personal data is necessary to comply with a legal obligation to which we are subject under any applicable law of the EU or under any applicable law of a country in which the GDPR applies in whole or in part, Art. 6 para. 1 lit. c GDPR serves as the legal basis. -- For the processing of personal data in order to protect vital interests of the data subject or another natural person, Art. 6 para. 1 lit. d DSGVO serves as the legal basis. -- If personal data is processed in order to protect the legitimate interests of us or of third parties and if the fundamental freedoms and rights and interests of the data subject do not override our interests and the interests of third parties, Article 6 (1) (f) of the GDPR serves as the legal basis. Legitimate interests are in particular our business interest in being able to provide our website and our products, information security, the enforcement of our own legal claims and compliance with Swiss law. +* Insofar as we obtain the consent of the data subject for processing operations, Art. 6 (1) a) DSGVO serves as the legal basis. +* When processing personal data for the fulfillment of a contract with the data subject as well as for the implementation of corresponding pre-contractual measures, Art. 6 para. 1 lit. b DSGVO serves as the legal basis. +* To the extent that processing of personal data is necessary to comply with a legal obligation to which we are subject under any applicable law of the EU or under any applicable law of a country in which the GDPR applies in whole or in part, Art. 6 para. 1 lit. c GDPR serves as the legal basis. +* For the processing of personal data in order to protect vital interests of the data subject or another natural person, Art. 6 para. 1 lit. d DSGVO serves as the legal basis. +* If personal data is processed in order to protect the legitimate interests of us or of third parties and if the fundamental freedoms and rights and interests of the data subject do not override our interests and the interests of third parties, Article 6 (1) (f) of the GDPR serves as the legal basis. Legitimate interests are in particular our business interest in being able to provide our website and our products, information security, the enforcement of our own legal claims and compliance with Swiss law. -We will retain personal data for the period of time necessary for the particular purpose for which it was collected. - -Subsequently, they are either deleted or made anonymous, unless we need them for a longer period of time in exceptional cases, e.g. due to legal storage and documentation obligations or our legitimate interests, such as the protection of rights to which we are entitled or the defense of claims. +We will retain personal data for the period of time necessary for the particular purpose for which it was collected and where we have an ongoing legitimate business need to do so (for example to comply with applicable legal, tax or accounting requirements). Subsequently, they are either deleted or made anonymous, unless we need them for a longer period of time in exceptional cases, e.g. due to legal storage and documentation obligations or our legitimate interests, such as the protection of rights to which we are entitled or the defense of claims. ### Processing of personal data when using the website, contact forms and in connection with newsletters @@ -57,45 +77,49 @@ Our websites can generally be visited without registration. Each time one of our This data is processed to enable correct delivery and functioning of the website. In addition, we use the data to optimize the website and to ensure the security of our systems. -Personal data, in particular name, address or e-mail address are collected as far as possible on a voluntary basis, for example when you contact us via a contact form or by e-mail. Without your consent, the data will not be passed on to third parties, unless shown in this privacy policy. +Personal data, in particular name, address or e-mail address are collected as far as possible on a voluntary basis, for example when you contact us via a contact form or by e-mail. Without your consent, the data will not be passed on to third parties, unless otherwise stated in this privacy policy. If you send us inquiries via contact form, your data from the form, including any data you provided, will be stored by us for the purpose of processing the inquiry and in case of follow-up questions. We do not pass on this data without your consent, except insofar as this is shown in this privacy policy. -If you would like to receive newsletters offered on our websites, we require an e-mail address from you as well as information that allows us to verify that you are the owner of the specified e-mail address and agree to receive the newsletter. Further data will not be collected. We use this data exclusively for sending the requested information and do not pass it on to third parties, except as described in this privacy policy. +If you would like to receive newsletters offered on our Sites, we require an e-mail address from you as well as information that allows us to verify that you are the owner of the specified e-mail address and agree to receive the newsletter. Further data will not be collected. We use this data exclusively for sending the requested information and do not pass it on to third parties, except as described in this privacy policy. You can revoke your consent to the storage of the data, the e-mail address and their use for sending the newsletter at any time, for example via the "unsubscribe link" in the newsletter. -### Processing of personal data in connection with the use of our products +### Processing of personal data when applying for a job with us + +Our Sites can generally be visited without registration. If you apply for a job with us, we may collect and process according to the [Privacy policy for the ZITADEL employer branding and recruitment](https://jobs.zitadel.com/privacy-policy). You may request and delete your data with the links on our [data & privacy page](https://jobs.zitadel.com/data-privacy). + +### Processing of personal data in connection with the use of our Services The use of our services is generally only possible with registration. During registration and in the course of using the services, we collect and process various personal data. In particular, the following personal data are part of the processing: - + +import { PiiTable } from "../../../src/components/pii_table"; + + Unless otherwise mentioned, the nature and purpose of the processing is as follows: -The data is uploaded by customers in our services or collected by us based on requests from users. The personal data is processed by us exclusively for the provision of the requested services or the use of the agreed services. +The data is uploaded by customers in our Services or collected by us based on requests from users. The personal data is processed by us exclusively for the provision of the requested Services or the use of the agreed Services. The fulfillment of the contract includes in particular, but is not limited to, the processing of personal data for the purpose of: -- Authentication and authorization of users -- Storage and processing of user actions in the audit trail -- Processing of personal data and login information -- Verification of communication means -- Communication regarding service interruptions or service changes +* Authentication and authorization of users +* Storage and processing of user actions in the audit trail +* Processing of personal data and login information +* Verification of communication means +* Communication regarding service interruptions or service changes ## Disclosure to third parties ### Third party sub-processors -We use third-party services to provide the website and our offers. An up-to-date list of all the providers we use and their areas of activity can be found on our [list of involved and approved sub-processors](../subprocessors). +We use third-party services to provide the website and our offers. An up-to-date list of all the providers we use and their areas of activity can be found on our [Trust Center](/trust). ### External payment providers -This website uses external payment service providers through whose platforms users and we can make payment transactions. For example via - -- [Stripe](https://stripe.com/ch/privacy) -- [Bexio AG](https://www.bexio.com/de-CH/datenschutz) +This Site uses external payment service providers through whose platforms users and we can make payment transactions. For example, via [Stripe](https://stripe.com/ch/privacy). As an alternative, we offer customers the option to pay by invoice instead of using external payment providers. However, this may require a positive credit check in advance. @@ -105,91 +129,166 @@ For payment transactions, the terms and conditions and the data protection notic ### Law enforcement -We disclose personal information to law enforcement agencies, investigative authorities or in legal proceedings to the extent we are required to do so by law or when necessary to protect our rights or the rights of users. +We disclose personal data to law enforcement agencies, investigative authorities or in legal proceedings to the extent we are required to do so by law or when necessary to protect our rights or the rights of users. ## Cookies -Our websites use cookies. These are small text files that make it possible to store specific information related to the user on the user's terminal device while the user is using the website. Cookies enable us, in particular, to offer a single sign-on procedure, to control the performance of our services, but also to make our offer more customer-friendly. Cookies remain stored beyond the end of a browser session and can be retrieved when the user visits the site again. +Our Sites use cookies. These are small text files that make it possible to store specific information related to the user on the user's terminal device while the user is using the website. Cookies enable us, in particular, to offer a single sign-on procedure, to control the performance of our Services, but also to make our offer more customer-friendly. Cookies remain stored beyond the end of a browser session and can be retrieved when the user visits the site again. -In particular, we use the following cookies to provide our services: - -When you use our services, we may collect information about your visit, including via cookies, beacons, invisible tags, and similar technologies (collectively “cookies”) in your browser and on emails sent to you. -This information may include Personal Information, such as your IP address, web browser, device type, and the web pages that you visit just before or just after you use the services, as well as information about your interactions with the services, such as the date and time of your visit, and where you have clicked. +When you use our Services, we may collect information about your visit, including via cookies, beacons, invisible tags, and similar technologies (collectively “cookies”) in your browser and on emails sent to you. This information may include personal data, such as your IP address, web browser, device type, and the web pages that you visit just before or just after you use the Services, as well as information about your interactions with the Services, such as the date and time of your visit, and where you have clicked. ### Necessary cookies -Some cookies are strictly necessary to make our services available to you. -We cannot provide you with our services without this type of cookies. +Some cookies are strictly necessary to make our Services available to you. We cannot provide you with our Services without this type of cookies. Necessary cookies provide basic functionality such as: -- Session Management -- Single Sign-On -- Rate Limiting -- DDoS Mitigation -- Remembering Preferences +* Session Management +* Single Sign-On +* Rate Limiting +* DDoS Mitigation +* Remembering Preferences ### Analytical cookies -We also use cookies for website analytics purposes in order to operate, maintain, and improve the services for you. -We use Google Analytics 4 to collect and process certain analytics data on our behalf. -Google Analytics helps us understand how you engage with the services and may also collect information about your use of other websites, apps, and online resources. -We don't use google analytics on customer instances of ZITADEL, only on our public websites and customer portal. +We also use cookies for website analytics purposes in order to operate, maintain, and improve the Services for you. We use Google Analytics 4 and PostHog to collect and process certain analytics data on our behalf. Google Analytics and PostHog helps us understand how you engage with the Services and may also collect information about your use of other websites, apps, and online resources. -You can learn about Google’s practices by going to https://www.google.com/policies/privacy/partners/ and opt out by managing your cookie consent through our services or an third-party tool of your choice. +You can learn about the analytics providers' practices by going to -If you do not want us to use cookies during your visit, you can disable their use in your browser settings. -In this case, certain parts of our website (e.g. language selection) may not function or may not function fully. -Where required by applicable law, we obtain your consent to use cookies. +* [https://www.google.com/policies/privacy/partners/](https://www.google.com/policies/privacy/partners/) +* [https://posthog.com/privacy](https://posthog.com/privacy) +* [https://legal.hubspot.com/privacy-policy](https://legal.hubspot.com/privacy-policy) +* [https://www.commonroom.io/privacy-policy/](https://www.commonroom.io/privacy-policy/) + +and opt out by managing your cookie consent through our Services or a third-party tool of your choice. + +If you do not want us to use cookies during your visit, you can disable their use in your browser settings. In this case, certain parts of our Sites (e.g. language selection) may not function or may not function fully. Where required by applicable law, we obtain your consent to use cookies. + +## How we protect personal data + +Personal data is maintained on our servers or those of our service providers, and is accessible by authorized employees, representatives, and agents as necessary for the purposes described in this privacy policy. + +We maintain a range of physical, electronic, and procedural safeguards designed to help protect personal data. While we attempt to protect your personal data in our possession, we cannot guarantee at all times the security of the data as no method of transmission over the internet or security system is perfect. + +If you choose to remain logged in, you should be aware that anyone with access to your device will be able to access your account and we therefore strongly recommend that you take appropriate steps to protect against unauthorized access to, and use, of your account. Please also notify us as soon as possible if you suspect any unauthorized use of your account or password. ## Rights of data subjects +Depending on your location and subject to applicable law, you may have the following rights regarding the personal data we process: + ### Right to information -Any person affected by the processing has the right to obtain information from the responsible data processor at any time about the personal data stored about him or her. +You have the right to know what personal data we hold and process about you and to access such personal data. ### Right to rectification -Every person affected by the processing has the right to demand the correction of inaccurate personal data concerning him or her. Furthermore, the data subject has the right to request the completion of incomplete personal data, taking into account the purposes of the processing. +You have the right to request the correction of inaccurate personal data concerning you. ### Right to erasure (right to be forgotten) -Any person affected by the processing has the right, in certain cases, to request from the responsible data processor to delete the personal data concerning him or her. +You have the right to request the deletion or erasure of the personal data concerning you. ### Right to restrict processing -Every person affected by the processing has the right in certain cases to request from the responsible data processor to restrict the processing. +You have the right to request to restrict the processing of your personal data in certain cases. ### Right to data portability -Every person affected by the processing has the right to receive the personal data concerning him or her in a structured, common and machine-readable format. He or she also has the right to have this data transferred to another data processor if the legal requirements are met. +You have the right to receive the personal data concerning you in a structured, common and machine-readable format, and to have this data transferred to another data processor if the legal requirements are met. ### Right to object -Every person affected by the processing has the right to object to the processing of personal data concerning him or her, insofar as we base the processing of his or her personal data on a balancing of interests. This is the case if the processing is not necessary, for example, to fulfill a contract or a legal obligation. +Depending on the circumstances, you have the right to object to the processing of personal data concerning you, insofar as we base the processing of your personal data on a balancing of interests. This is the case if the processing is not necessary, for example, to fulfill a contract or a legal obligation. -To exercise such an objection, the data subject must explain his or her reasons why we should not process his or her personal data as we have done. We will then review the situation and either stop or adjust the data processing or show the data subject our reasons for continuing the processing. +To exercise such an objection, please indicate your reasons why we should not process your personal data as we have done. We will then review the situation and either stop or adjust the data processing or explain our reasons for continuing the processing. ### Right to revoke consent under data protection law -Insofar as our processing is based on consent, the data subject has the right to revoke this consent at any time with effect for the future. +Insofar as our processing is based on consent, you have the right to revoke your consent at any time with effect. Withdrawing your consent will not affect the lawfulness of any processing we conducted prior to your withdrawal, nor will it affect processing of your personal data conducted in reliance on lawful processing grounds other than consent. ### Assertion of rights by the data subjects If you wish to exercise your rights, you may do so by contacting the above-mentioned contact person. -A data subject also has the right to lodge a complaint with the competent data protection authority. The competent data protection authority in Switzerland is the Federal Data Protection and Information Commissioner (www.edoeb.admin.ch). The competent data protection authorities of EU countries can be viewed at this link: [https://ec.europa.eu/justice/article-29/structure/data-protection-authorities/index\_en.htm](https://ec.europa.eu/justice/article-29/structure/data-protection-authorities/index_en.htm) +You can opt out of receiving marketing emails from us by following the unsubscribe link in the emails or by emailing us. If you choose to no longer receive marketing information, we may still communicate with you regarding such things as your security updates, product functionality, responses to service requests, or other transactional, non-marketing purposes. -## Note on data transfer abroad +If you have a concern about how we collect and use personal data, please contact us using the contact details provided at the beginning of this privacy policy. You also have the right to contact your local data protection authority if you prefer, such as: -Our websites and services make use of tools from companies based in countries outside of Switzerland or the EU/EEA, namely those based in the USA. When these tools are active, your personal data may be transferred to the servers of the respective companies abroad. We would like to point out that some of these countries, namely the USA, are not a safe third country in the sense of Swiss and EU data protection law. In these cases, we only transfer personal data after we have implemented the legally required measures for this, such as concluding standard contractual clauses on data protection or obtaining the consent of the data subjects. If interested, the documentation on these measures can be obtained from the contact person mentioned above. +* Data protection authorities in the European Economic Area (EEA): [https://edpb.europa.eu/about-edpb/board/members\_en](https://edpb.europa.eu/about-edpb/board/members_en); +* Swiss data protection authorities: [https://www.edoeb.admin.ch/edoeb/en/home/deredoeb/kontakt.html](https://www.edoeb.admin.ch/edoeb/en/home/deredoeb/kontakt.html); +* UK data protection authority: [https://ico.org.uk/global/contact-us/](https://ico.org.uk/global/contact-us/). + +## Additional Information for U.S. Residents + +Categories of personal data we collect and our purposes for collection and use +You can find a list of the categories of personal data that we collect in the section above titled “Processing of personal data, legal basis, storage period”. In the last 12 months, we collected the following categories of personal data depending on the Services used: + +* Identifiers and account information, such as the username and email address; +* Commercial information, such as information about transactions undertaken with us; +* Internet or other electronic network activity information, such as information about activity on our Site and Services. +* Geolocation information based on the IP address. +* Audiovisual information in pictures, audio, or video content that you may choose to submit to us. +* Professional or employment-related information or demographic information, but only if you explicitly provide it to us, such as by filling out a survey or by applying for a job with us. +* Inferences we make based on other collected data, for purposes such as recommending content and analytics. + +For details regarding the sources from which we obtain personal data, please see the “Processing of personal data, legal basis, storage period” section above. +We collect and use personal data for the business or commercial purposes described in the “Processing of personal data, legal basis, storage period” section above. + +Categories of personal data disclosed and categories of recipients + +We disclose the following categories of personal data for business or commercial purposes to the categories of recipients listed below: + +* We disclose identifiers with businesses, service providers, and third parties, such as analytics providers and social media networks. + * We disclose Internet or other network activity with businesses, service providers, and third parties, such as analytics providers and social media networks. + * We disclose geolocation information with businesses, service providers, and third parties such as advertising networks, analytics, and social media. + * We disclose payment information with businesses and service providers who process payments. + * We disclose commercial information with businesses, service providers, and third parties, such as analytics providers and social media networks. + * We disclose audiovisual information with businesses and service providers who help administer customer service and fraud or loss prevention services. + * We disclose inferences with businesses and service providers who help administer marketing and personalization. + +### Privacy rights + +Right to Opt-Out of Cookies and Sale/Sharing: Although we do not sell personal data for monetary value, our use of cookies and automated technologies may be considered a “sale” / “sharing” in certain states, such as California. Visitors to our US website can opt out of such third parties by clicking the “Manage cookie preferences” link at the bottom of our Site. The categories of personal data disclosed that may be considered a “sale” / “sharing” include identifiers, device information, Internet or other network activity, geolocation data, and commercial data. + +The categories of third parties to whom personal data was disclosed that may be considered “sale”/ “sharing” include data analytics providers and social media networks. + +We do not have actual knowledge that we sell or share the personal data of individuals under 16 years of age. + +If you are a resident of the State of Nevada, Chapter 603A of the Nevada Revised Statutes permits a Nevada resident to opt out of future sales of certain covered information that a website operator has collected or will collect about the resident. Although we do not currently sell covered information, please contact us to submit such a request. + +Right to Limit the Use of Sensitive Personal Information: We only collect sensitive personal information, as defined by applicable privacy laws, for the purposes allowed by law or with your consent. We do not use or disclose sensitive personal information except to provide you the Services or as otherwise permitted by law. We do not collect or process sensitive personal information for the purpose of inferring characteristics. + +Right to Access, Correct, and Delete Personal Data: Depending on your state of residence in the U.S., you may have: +(i) the right to request access to and receive details about the personal data we maintain and how we have processed it, including the categories of personal data, the categories of sources from which personal data is collected, the business or commercial purpose for collecting, selling, or sharing personal data, the categories of third parties to whom personal data is disclosed, and the specific pieces of personal data collected; +(ii) the right to delete personal data collected, subject to certain exceptions; +(iii) the right to correct inaccurate personal data. + +When you make a request, we will verify your identity by asking you to sign into your account or if necessary by requesting additional information from you. You may also make a request using an authorized agent. If you submit a rights request through an authorized agent, we may ask such agent to provide proof that you gave a signed permission to submit the request to exercise privacy rights on your behalf. We may also require you to verify your own identity directly with us or confirm to us that you otherwise provided such agent permission to submit the request. Once you have submitted your request, we will respond within the time frame permitted by the applicable law. + +If you have any questions or concerns, you may reach us by contacting using one of the contact details listed at the beginning of this privacy policy. + +Depending on your state of residence, you may be able to appeal our decision to your request regarding your personal data. To do so, please contact us by using one of the contact details listed at the beginning of this privacy policy. We respond to all appeal requests as soon as we reasonably can, and no later than legally required. + +We do not discriminate against customers who exercise any of their rights described in our privacy policy. + +California Shine the Light: Customers who are residents of California may request information concerning the categories of personal data (if any) we disclose to third parties or affiliates for their direct marketing purposes. If you would like more information, please submit a written request to us by using one of the contact details listed at the beginning of this privacy policy. + +Do Not Track signals: Most modern web browsers give you the option to send a 'Do Not Track' signal to the sites you visit, indicating that you do not wish to be tracked. However, there is currently no accepted standard for how a site should respond to this signal, and we do not take any action in response to this signal.‍ + +## Note on international data transfers + +Our Sites and Services make use of tools from companies based in countries outside of Switzerland or the EU/EEA, namely those based in the USA. When these tools are active, your personal data may be transferred to the servers of the respective companies abroad. If you are using the Site or Services from outside the United States, your personal data may be processed in a foreign country, where privacy laws may be less stringent than the laws in your country. In these cases, we only transfer personal data after we have implemented the legally required measures for this, such as concluding standard contractual clauses on data protection or obtaining the consent of the data subjects. If interested, the documentation on these measures can be obtained from the contact person mentioned above. By submitting your personal data to us you agree to the transfer, storage, and processing of your personal data in a country other than your country of residence including, but not necessarily limited to, the United States. We actively try to minimize the use of tools from companies located in countries without equivalent data protection, however, due to the lack of alternatives, this is currently not always feasible without major inconvenience. If you have any concerns, please contact us directly and we will try to find a mutual solution for your needs. -## Changes +## Children's Privacy -We may amend this privacy policy at any time without prior notice. Always the current version published on our website applies to users and customers of our website and services. Insofar as the data protection declaration is part of an agreement with you, we will inform you of the change by e-mail or other suitable means in the event of an update. +Our Site is not intended for or directed to children under the age of 14. We do not knowingly collect personal data directly from children under the age of 14 without parental consent. If we become aware that a child under the age of 14 has provided us with personal data, we will delete the information from our records. -## Questions about data processing by us +## Changes to this Privacy Policy -If you have any questions about our data processing, please email us or contact the person in our organization listed at the beginning of this privacy statement directly. +We may revise this privacy policy from time to time and will post the date it was last updated at the top of this privacy policy. We will provide additional notice to you if we make any changes that materially affect your privacy rights. + +## Contact us + +If you have any questions about our data processing, please email us or contact us by using the contact details listed at the beginning of this privacy notice. diff --git a/docs/src/components/pii_table.jsx b/docs/src/components/pii_table.jsx new file mode 100644 index 0000000000..5075e1d2ca --- /dev/null +++ b/docs/src/components/pii_table.jsx @@ -0,0 +1,106 @@ +import React from "react"; + +export function PiiTable() { + + const pii = [ + { + type: "Basic data", + examples: [ + 'Names', + 'Email addresses', + 'User names' + ], + subjects: "All users as uploaded by Customer." + }, + { + type: "Login data", + examples: [ + 'Randomly generated ID', + 'Passwords', + 'Public keys / certificates ("FIDO2", "U2F", "x509", ...)', + 'User names or identifiers of external login providers', + 'Phone numbers', + ], + subjects: "All users as uploaded and feature use by Customer." + }, + { + type: "Profile data", + examples: [ + 'Profile pictures', + 'Gender', + 'Languages', + 'Nicknames or Display names', + 'Phone numbers', + 'Metadata' + ], + subjects: "All users as uploaded by Customer" + }, + { + type: "Communication data", + examples: [ + 'Emails', + 'Chats', + 'Call metadata', + 'Call recording and transcripts', + 'Form submissions', + ], + subjects: "Customers and users who communicate with us directly (e.g. support, chat)." + }, + { + type: "Payment data", + examples: [ + 'Billing address', + 'Payment information', + 'Customer number', + 'Support Customer history', + 'Credit rating information', + ], + subjects: "Customers who use services that require payment. Credit rating information: Only customers who pay by invoice." + }, + { + type: "Analytics data", + examples: [ + 'Usage metrics', + 'User behavior', + 'User journeys (eg, Milestones)', + 'Telemetry data', + 'Client-side anonymized session replay', + ], + subjects: "Customers who use our services." + }, + { + type: "Usage meta data", + examples: [ + 'User agent', + 'IP addresses', + 'Operating system', + 'Time and date', + 'URL', + 'Referrer URL', + 'Accepted Language', + ], + subjects: "All users" + }, + ] + + return ( +
Reorder @@ -48,7 +48,7 @@ actions matTooltip="{{ 'ACTIONS.REMOVE' | translate }}" color="warn" - (click)="removeTarget(i)" + (click)="removeTarget(i, form)" mat-icon-button > @@ -65,7 +65,7 @@ {{ 'ACTIONS.BACK' | translate }} - diff --git a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts index e04368f8f4..60b4025650 100644 --- a/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts +++ b/console/src/app/modules/actions-two/actions-two-add-action/actions-two-add-action-target/actions-two-add-action-target.component.ts @@ -14,7 +14,7 @@ import { RouterModule } from '@angular/router'; import { TranslateModule } from '@ngx-translate/core'; import { MatButtonModule } from '@angular/material/button'; import { FormBuilder, FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ReplaySubject, switchMap } from 'rxjs'; +import { ObservedValueOf, ReplaySubject, shareReplay, switchMap } from 'rxjs'; import { MatRadioModule } from '@angular/material/radio'; import { ActionService } from 'src/app/services/action.service'; import { ToastService } from 'src/app/services/toast.service'; @@ -23,14 +23,13 @@ import { InputModule } from 'src/app/modules/input/input.module'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MessageInitShape } from '@bufbuild/protobuf'; import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; -import { ExecutionTargetTypeSchema } from '@zitadel/proto/zitadel/action/v2beta/execution_pb'; import { MatSelectModule } from '@angular/material/select'; import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; -import { startWith } from 'rxjs/operators'; +import { map, startWith } from 'rxjs/operators'; import { TypeSafeCellDefModule } from 'src/app/directives/type-safe-cell-def/type-safe-cell-def.module'; import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { toSignal } from '@angular/core/rxjs-interop'; import { minArrayLengthValidator } from '../../../form-field/validators/validators'; import { ProjectRoleChipModule } from '../../../project-role-chip/project-role-chip.module'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -72,11 +71,12 @@ export class ActionsTwoAddActionTargetComponent { } @Output() public readonly back = new EventEmitter(); - @Output() public readonly continue = new EventEmitter[]>(); + @Output() public readonly continue = new EventEmitter(); private readonly preselectedTargetIds$ = new ReplaySubject(1); - protected readonly form: ReturnType; + protected readonly form$: ReturnType; + protected readonly targets: ReturnType; private readonly selectedTargetIds: Signal; protected readonly selectableTargets: Signal; @@ -87,26 +87,27 @@ export class ActionsTwoAddActionTargetComponent { private readonly actionService: ActionService, private readonly toast: ToastService, ) { - this.form = this.buildForm(); + this.form$ = this.buildForm().pipe(shareReplay({ refCount: true, bufferSize: 1 })); this.targets = this.listTargets(); - this.selectedTargetIds = this.getSelectedTargetIds(this.form); - this.selectableTargets = this.getSelectableTargets(this.targets, this.selectedTargetIds); + this.selectedTargetIds = this.getSelectedTargetIds(this.form$); + this.selectableTargets = this.getSelectableTargets(this.targets, this.selectedTargetIds, this.form$); this.dataSource = this.getDataSource(this.targets, this.selectedTargetIds); } private buildForm() { - const preselectedTargetIds = toSignal(this.preselectedTargetIds$, { initialValue: [] as string[] }); - - return computed(() => { - return this.fb.group({ - autocomplete: new FormControl('', { nonNullable: true }), - selectedTargetIds: new FormControl(preselectedTargetIds(), { - nonNullable: true, - validators: [minArrayLengthValidator(1)], - }), - }); - }); + return this.preselectedTargetIds$.pipe( + startWith([] as string[]), + map((preselectedTargetIds) => { + return this.fb.group({ + autocomplete: new FormControl('', { nonNullable: true }), + selectedTargetIds: new FormControl(preselectedTargetIds, { + nonNullable: true, + validators: [minArrayLengthValidator(1)], + }), + }); + }), + ); } private listTargets() { @@ -129,25 +130,35 @@ export class ActionsTwoAddActionTargetComponent { return computed(targetsSignal); } - private getSelectedTargetIds(form: typeof this.form) { - const selectedTargetIds$ = toObservable(form).pipe( - startWith(form()), - switchMap((form) => { - const { selectedTargetIds } = form.controls; + private getSelectedTargetIds(form$: typeof this.form$) { + const selectedTargetIds$ = form$.pipe( + switchMap(({ controls: { selectedTargetIds } }) => { return selectedTargetIds.valueChanges.pipe(startWith(selectedTargetIds.value)); }), ); return toSignal(selectedTargetIds$, { requireSync: true }); } - private getSelectableTargets(targets: typeof this.targets, selectedTargetIds: Signal) { - return computed(() => { + private getSelectableTargets(targets: typeof this.targets, selectedTargetIds: Signal, form$: typeof this.form$) { + const autocomplete$ = form$.pipe( + switchMap(({ controls: { autocomplete } }) => { + return autocomplete.valueChanges.pipe(startWith(autocomplete.value)); + }), + ); + const autocompleteSignal = toSignal(autocomplete$, { requireSync: true }); + + const unselectedTargets = computed(() => { const targetsCopy = new Map(targets().targets); for (const selectedTargetId of selectedTargetIds()) { targetsCopy.delete(selectedTargetId); } return Array.from(targetsCopy.values()); }); + + return computed(() => { + const autocomplete = autocompleteSignal().toLowerCase(); + return unselectedTargets().filter(({ name }) => name.toLowerCase().includes(autocomplete)); + }); } private getDataSource(targetsSignal: typeof this.targets, selectedTargetIdsSignal: Signal) { @@ -178,46 +189,39 @@ export class ActionsTwoAddActionTargetComponent { return dataSource; } - protected addTarget(target: Target) { - const { selectedTargetIds } = this.form().controls; + protected addTarget(target: Target, form: ObservedValueOf) { + const { selectedTargetIds } = form.controls; selectedTargetIds.setValue([target.id, ...selectedTargetIds.value]); - this.form().controls.autocomplete.setValue(''); + form.controls.autocomplete.setValue(''); } - protected removeTarget(index: number) { - const { selectedTargetIds } = this.form().controls; + protected removeTarget(index: number, form: ObservedValueOf) { + const { selectedTargetIds } = form.controls; const data = [...selectedTargetIds.value]; data.splice(index, 1); selectedTargetIds.setValue(data); } - protected drop(event: CdkDragDrop) { - const { selectedTargetIds } = this.form().controls; + protected drop(event: CdkDragDrop, form: ObservedValueOf) { + const { selectedTargetIds } = form.controls; const data = [...selectedTargetIds.value]; moveItemInArray(data, event.previousIndex, event.currentIndex); selectedTargetIds.setValue(data); } - protected handleEnter(event: Event) { + protected handleEnter(event: Event, form: ObservedValueOf) { const selectableTargets = this.selectableTargets(); if (selectableTargets.length !== 1) { return; } event.preventDefault(); - this.addTarget(selectableTargets[0]); + this.addTarget(selectableTargets[0], form); } protected submit() { - const selectedTargets = this.selectedTargetIds().map((value) => ({ - type: { - case: 'target' as const, - value, - }, - })); - - this.continue.emit(selectedTargets); + this.continue.emit(this.selectedTargetIds()); } protected trackTarget(_: number, target: Target) { diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html index 37d4f89dd0..717ee8f850 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.html @@ -26,6 +26,9 @@ {{ 'ACTIONSTWO.TARGET.CREATE.TYPES.' + type | translate }} + + {{ 'ACTIONSTWO.TARGET.CREATE.TYPES_DESCRIPTION' | translate }} + diff --git a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.scss b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.scss index 34a7d5203d..a68e1e87cb 100644 --- a/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.scss +++ b/console/src/app/modules/actions-two/actions-two-add-target/actions-two-add-target-dialog.component.scss @@ -23,3 +23,7 @@ .name-hint { font-size: 12px; } + +.types-description { + white-space: pre-line; +} diff --git a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.html b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.html index a6bde66e41..23b3b4bb89 100644 --- a/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.html +++ b/console/src/app/modules/actions-two/actions-two-targets/actions-two-targets.component.html @@ -1,4 +1,7 @@

{{ 'ACTIONSTWO.TARGET.TITLE' | translate }}

+ + {{ 'ACTIONSTWO.BETA_NOTE' | translate }} +

{{ 'ACTIONSTWO.TARGET.DESCRIPTION' | translate }}

setTimeout(res, 1000)); @@ -86,4 +88,6 @@ export class ActionsTwoTargetsComponent { this.toast.showError(error); } } + + protected readonly InfoSectionType = InfoSectionType; } diff --git a/console/src/app/modules/actions-two/actions-two.module.ts b/console/src/app/modules/actions-two/actions-two.module.ts index 45d70193f9..a940264eb2 100644 --- a/console/src/app/modules/actions-two/actions-two.module.ts +++ b/console/src/app/modules/actions-two/actions-two.module.ts @@ -20,6 +20,7 @@ import { ProjectRoleChipModule } from '../project-role-chip/project-role-chip.mo import { ActionConditionPipeModule } from 'src/app/pipes/action-condition-pipe/action-condition-pipe.module'; import { MatSelectModule } from '@angular/material/select'; import { MatIconModule } from '@angular/material/icon'; +import { InfoSectionModule } from '../info-section/info-section.module'; @NgModule({ declarations: [ @@ -47,6 +48,7 @@ import { MatIconModule } from '@angular/material/icon'; TypeSafeCellDefModule, ProjectRoleChipModule, ActionConditionPipeModule, + InfoSectionModule, ], exports: [ActionsTwoActionsComponent, ActionsTwoTargetsComponent, ActionsTwoTargetsTableComponent], }) diff --git a/console/src/app/modules/settings-list/settings.ts b/console/src/app/modules/settings-list/settings.ts index 79b92e2214..c96431fa30 100644 --- a/console/src/app/modules/settings-list/settings.ts +++ b/console/src/app/modules/settings-list/settings.ts @@ -231,6 +231,7 @@ export const ACTIONS: SidenavSetting = { // todo: figure out roles [PolicyComponentServiceType.ADMIN]: ['iam.policy.read'], }, + beta: true, }; export const ACTIONS_TARGETS: SidenavSetting = { @@ -241,4 +242,5 @@ export const ACTIONS_TARGETS: SidenavSetting = { // todo: figure out roles [PolicyComponentServiceType.ADMIN]: ['iam.policy.read'], }, + beta: true, }; diff --git a/console/src/app/modules/sidenav/sidenav.component.html b/console/src/app/modules/sidenav/sidenav.component.html index 277686852b..78e21e79f6 100644 --- a/console/src/app/modules/sidenav/sidenav.component.html +++ b/console/src/app/modules/sidenav/sidenav.component.html @@ -28,6 +28,7 @@ [attr.data-e2e]="'sidenav-element-' + setting.id" > {{ setting.i18nKey | translate }} + {{ 'SETTINGS.BETA' | translate }} diff --git a/console/src/app/modules/sidenav/sidenav.component.scss b/console/src/app/modules/sidenav/sidenav.component.scss index 383857751c..bb55a6999d 100644 --- a/console/src/app/modules/sidenav/sidenav.component.scss +++ b/console/src/app/modules/sidenav/sidenav.component.scss @@ -90,6 +90,10 @@ flex-shrink: 0; } + .state { + margin-left: 0.5rem; + } + &:hover { span { opacity: 1; diff --git a/console/src/app/modules/sidenav/sidenav.component.ts b/console/src/app/modules/sidenav/sidenav.component.ts index 33539750a2..4b73491f08 100644 --- a/console/src/app/modules/sidenav/sidenav.component.ts +++ b/console/src/app/modules/sidenav/sidenav.component.ts @@ -11,6 +11,7 @@ export interface SidenavSetting { [PolicyComponentServiceType.ADMIN]?: string[]; }; showWarn?: boolean; + beta?: boolean; } @Component({ diff --git a/console/src/app/pages/actions/actions.component.html b/console/src/app/pages/actions/actions.component.html index 4c55308ced..af936e2351 100644 --- a/console/src/app/pages/actions/actions.component.html +++ b/console/src/app/pages/actions/actions.component.html @@ -6,6 +6,9 @@ info_outline + + {{ 'DESCRIPTIONS.ACTIONS.ACTIONSTWO_NOTE' | translate }} +

{{ 'DESCRIPTIONS.ACTIONS.DESCRIPTION' | translate }}

= new Subject(); + protected maxActions: number | null = null; + protected ActionState = ActionState; constructor( private mgmtService: ManagementService, breadcrumbService: BreadcrumbService, private dialog: MatDialog, private toast: ToastService, + destroyRef: DestroyRef, ) { const bread: Breadcrumb = { type: BreadcrumbType.ORG, @@ -45,31 +45,24 @@ export class ActionsComponent implements OnDestroy { }; breadcrumbService.setBreadcrumb([bread]); - this.getFlowTypes(); + this.getFlowTypes().then(); - this.typeControl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((value) => { + this.typeControl.valueChanges.pipe(takeUntilDestroyed(destroyRef)).subscribe((value) => { this.loadFlow((value as FlowType.AsObject).id); }); } - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - private getFlowTypes(): Promise { - return this.mgmtService - .listFlowTypes() - .then((resp) => { - this.typesForSelection = resp.resultList; - if (!this.flow && resp.resultList[0]) { - const type = resp.resultList[0]; - this.typeControl.setValue(type); - } - }) - .catch((error: any) => { - this.toast.showError(error); - }); + private async getFlowTypes(): Promise { + try { + let resp = await this.mgmtService.listFlowTypes(); + this.typesForSelection = resp.resultList; + if (!this.flow && resp.resultList[0]) { + const type = resp.resultList[0]; + this.typeControl.setValue(type); + } + } catch (error) { + this.toast.showError(error); + } } private loadFlow(id: string) { @@ -106,7 +99,7 @@ export class ActionsComponent implements OnDestroy { }); } - public openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void { + protected openAddTrigger(flow: FlowType.AsObject, trigger?: TriggerType.AsObject): void { const dialogRef = this.dialog.open(AddFlowDialogComponent, { data: { flowType: flow, @@ -119,7 +112,7 @@ export class ActionsComponent implements OnDestroy { if (req) { this.mgmtService .setTriggerActions(req.getActionIdsList(), req.getFlowType(), req.getTriggerType()) - .then((resp) => { + .then(() => { this.toast.showInfo('FLOWS.FLOWCHANGED', true); this.loadFlow(flow.id); }) @@ -157,7 +150,7 @@ export class ActionsComponent implements OnDestroy { } } - public removeTriggerActionsList(index: number) { + protected removeTriggerActionsList(index: number) { if (this.flow.type && this.flow.triggerActionsList && this.flow.triggerActionsList[index]) { const dialogRef = this.dialog.open(WarnDialogComponent, { data: { diff --git a/console/src/app/pages/instance/instance.component.ts b/console/src/app/pages/instance/instance.component.ts index 546553132d..e52cdd7198 100644 --- a/console/src/app/pages/instance/instance.component.ts +++ b/console/src/app/pages/instance/instance.component.ts @@ -42,8 +42,6 @@ import { SidenavSetting } from 'src/app/modules/sidenav/sidenav.component'; import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; import { EnvironmentService } from 'src/app/services/environment.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { NewFeatureService } from '../../services/new-feature.service'; -import { withLatestFromSynchronousFix } from '../../utils/withLatestFromSynchronousFix'; @Component({ selector: 'cnsl-instance', templateUrl: './instance.component.html', @@ -106,7 +104,6 @@ export class InstanceComponent { private readonly envService: EnvironmentService, activatedRoute: ActivatedRoute, private readonly destroyRef: DestroyRef, - private readonly featureService: NewFeatureService, ) { this.loadMembers(); @@ -139,32 +136,7 @@ export class InstanceComponent { } private getSettingsList(): Observable { - const features$ = this.getFeatures().pipe(shareReplay({ refCount: true, bufferSize: 1 })); - - const actionsEnabled$ = features$.pipe(map((features) => features?.actions?.enabled)); - - return this.authService - .isAllowedMapper(this.defaultSettingsList, (setting) => setting.requiredRoles.admin || []) - .pipe( - withLatestFromSynchronousFix(actionsEnabled$), - map(([settings, actionsEnabled]) => - settings - .filter((setting) => actionsEnabled || setting.id !== ACTIONS.id) - .filter((setting) => actionsEnabled || setting.id !== ACTIONS_TARGETS.id), - ), - ); - } - - private getFeatures() { - return defer(() => this.featureService.getInstanceFeatures()).pipe( - timeout(1000), - catchError((error) => { - if (!(error instanceof TimeoutError)) { - this.toast.showError(error); - } - return of(undefined); - }), - ); + return this.authService.isAllowedMapper(this.defaultSettingsList, (setting) => setting.requiredRoles.admin || []); } public loadMembers(): void { diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 4bd94d5ce6..30d9c53763 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Потоци", "DESCRIPTION": "Изберете поток за удостоверяване и активирайте вашето действие при конкретно събитие в този поток." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, нова и подобрена версия на Actions, вече е налична. Настоящата версия все още е достъпна, но бъдещото развитие ще бъде фокусирано върху новата, която в крайна сметка ще замени текущата версия." }, "SETTINGS": { "INSTANCE": { @@ -528,6 +529,7 @@ "APPLY": "Прилагам" }, "ACTIONSTWO": { + "BETA_NOTE": "В момента използвате новата версия Actions V2, която е в бета фаза. Предишната версия 1 все още е достъпна, но ще бъде спряна в бъдеще. Моля, съобщавайте за всякакви проблеми или изпратете обратна връзка.", "EXECUTION": { "TITLE": "Действия", "DESCRIPTION": "Действията ви позволяват да изпълнявате персонализиран код в отговор на API заявки, събития или специфични функции. Използвайте ги, за да разширите Zitadel, да автоматизирате работни процеси и да се интегрирате с други системи.", @@ -618,6 +620,7 @@ "restCall": "REST извикване", "restAsync": "REST асинхронно" }, + "TYPES_DESCRIPTION": "Webhook, обаждането обработва кода на състоянието, но отговорът е без значение\nCall, обаждането обработва кода на състоянието и отговора\nAsync, обаждането не обработва нито кода на състоянието, нито отговора, но може да бъде извикано паралелно с други цели", "ENDPOINT": "Крайна точка", "ENDPOINT_DESCRIPTION": "Въведете крайната точка, където се хоства вашият код. Уверете се, че е достъпна за нас!", "TIMEOUT": "Време за изчакване", @@ -1507,7 +1510,8 @@ "APPEARANCE": "Външен вид", "OTHER": "други", "STORAGE": "Съхранение" - } + }, + "BETA": "БЕТА" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 6dd066789e..ee43e86822 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Flows", "DESCRIPTION": "Vyberte proces autentizace a spusťte vaši akci na konkrétní události v rámci tohoto procesu." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, nová a vylepšená verze Actions, je nyní k dispozici. Aktuální verze je stále přístupná, ale budoucí vývoj se zaměří na novou verzi, která nakonec nahradí tu současnou." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Platit" }, "ACTIONSTWO": { + "BETA_NOTE": "Aktuálně používáte novou verzi Actions V2, která je v beta verzi. Předchozí verze 1 je stále k dispozici, ale v budoucnu bude ukončena. Prosím, hlaste jakékoliv problémy nebo zpětnou vazbu.", "EXECUTION": { "TITLE": "Akce", "DESCRIPTION": "Akce vám umožňují spouštět vlastní kód v reakci na požadavky API, události nebo specifické funkce. Použijte je k rozšíření Zitadel, automatizaci pracovních postupů a integraci s dalšími systémy.", @@ -619,6 +621,7 @@ "restCall": "REST Volání", "restAsync": "REST Asynchronní" }, + "TYPES_DESCRIPTION": "Webhook, volání zpracovává stavový kód, ale odpověď je irelevantní\nCall, volání zpracovává stavový kód a odpověď\nAsync, volání nezpracovává ani stavový kód, ani odpověď, ale může být spuštěno paralelně s jinými cíli", "ENDPOINT": "Koncový bod", "ENDPOINT_DESCRIPTION": "Zadejte koncový bod, kde je hostován váš kód. Ujistěte se, že je pro nás přístupný!", "TIMEOUT": "Časový limit", @@ -1508,7 +1511,8 @@ "APPEARANCE": "Vzhled", "OTHER": "Ostatní", "STORAGE": "Data" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index cb973006e4..3993674992 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Flows", "DESCRIPTION": "Wähle einen Authentifizierungsflow und löse deine Aktionen bei einem spezifischen Ereignis innerhalb dieses Flows aus." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, eine neue und verbesserte Version von Actions, ist jetzt verfügbar. Die aktuelle Version ist weiterhin zugänglich, aber unsere zukünftige Entwicklung wird sich auf die neue Version konzentrieren, die schließlich die aktuelle ersetzen wird." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Anwenden" }, "ACTIONSTWO": { + "BETA_NOTE": "Sie verwenden derzeit die neuen Actions V2, die sich in der Beta-Phase befinden. Version 1 ist weiterhin verfügbar, wird jedoch in Zukunft eingestellt. Bitte melden Sie Probleme oder Feedback.", "EXECUTION": { "TITLE": "Aktionen", "DESCRIPTION": "Aktionen ermöglichen es Ihnen, benutzerdefinierten Code als Reaktion auf API-Anfragen, Ereignisse oder bestimmte Funktionen auszuführen. Verwenden Sie sie, um Zitadel zu erweitern, Arbeitsabläufe zu automatisieren und sich in andere Systeme zu integrieren.", @@ -619,6 +621,7 @@ "restCall": "REST Aufruf", "restAsync": "REST Asynchron" }, + "TYPES_DESCRIPTION": "Webhook, der Aufruf verarbeitet den Statuscode, aber die Antwort ist irrelevant\nCall, der Aufruf verarbeitet den Statuscode und die Antwort\nAsync, der Aufruf verarbeitet weder Statuscode noch Antwort, kann aber parallel zu anderen Zielen aufgerufen werden", "ENDPOINT": "Endpunkt", "ENDPOINT_DESCRIPTION": "Geben Sie den Endpunkt ein, an dem Ihr Code gehostet wird. Stellen Sie sicher, dass er für uns zugänglich ist!", "TIMEOUT": "Timeout", @@ -1508,7 +1511,8 @@ "APPEARANCE": "Erscheinungsbild", "OTHER": "Anderes", "STORAGE": "Speicher" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index be0a3d3f17..fd81bfd353 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Flows", "DESCRIPTION": "Choose an authentication flow and trigger your action on a specific event within this flow." - } + }, + "ACTIONSTWO_NOTE": "Actions V2 a new, improved version of Actions is now available. The current version is still accessible, but our future development will focus on the new one, which will eventually replace the current version." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Apply" }, "ACTIONSTWO": { + "BETA_NOTE": "You are currently using the new Actions V2, which is in beta. The previous Version 1 is still available but will be discontinued in the future. Please report any issues or feedback.", "EXECUTION": { "TITLE": "Actions", "DESCRIPTION": "Actions let you run custom code in response to API requests, events or specific functions. Use them to extend Zitadel, automate workflows, and itegrate with other systems.", @@ -619,6 +621,7 @@ "restCall": "REST Call", "restAsync": "REST Async" }, + "TYPES_DESCRIPTION": "Webhook, the call handles the status code but response is irrelevant\nCall, the call handles the status code and response\nAsync, the call handles neither status code nor response, but can be called in parallel with other Targets", "ENDPOINT": "Endpoint", "ENDPOINT_DESCRIPTION": "Enter the endpoint where your code is hosted. Make sure it is accessible to us!", "TIMEOUT": "Timeout", @@ -1511,7 +1514,8 @@ "OTHER": "Other", "STORAGE": "Storage", "ACTIONS": "Actions" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 0fc95241af..aec024eacb 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Flujos", "DESCRIPTION": "Elige un flujo de autenticación y activa tu acción en un evento específico dentro de este flujo." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, una nueva y mejorada versión de Actions, ya está disponible. La versión actual sigue siendo accesible, pero nuestro desarrollo futuro se centrará en la nueva, que acabará reemplazando la versión actual." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Aplicar" }, "ACTIONSTWO": { + "BETA_NOTE": "Actualmente estás usando la nueva versión Actions V2, que está en fase beta. La versión anterior 1 todavía está disponible, pero será descontinuada en el futuro. Por favor, informa de cualquier problema o comentario.", "EXECUTION": { "TITLE": "Acciones", "DESCRIPTION": "Las acciones te permiten ejecutar código personalizado en respuesta a solicitudes de API, eventos o funciones específicas. Úsalas para extender Zitadel, automatizar flujos de trabajo e integrarte con otros sistemas.", @@ -619,6 +621,7 @@ "restCall": "Llamada REST", "restAsync": "REST Asíncrono" }, + "TYPES_DESCRIPTION": "Webhook, la llamada maneja el código de estado pero la respuesta es irrelevante\nCall, la llamada maneja el código de estado y la respuesta\nAsync, la llamada no maneja ni el código de estado ni la respuesta, pero puede ser llamada en paralelo con otros objetivos", "ENDPOINT": "Punto de conexión", "ENDPOINT_DESCRIPTION": "Introduce el punto de conexión donde se aloja tu código. ¡Asegúrate de que sea accesible para nosotros!", "TIMEOUT": "Tiempo de espera", @@ -1509,7 +1512,8 @@ "APPEARANCE": "Apariencia", "OTHER": "Otros", "STORAGE": "Datos" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 60a0a3e482..05e34ad846 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Flux", "DESCRIPTION": "Choisissez un flux d'authentification et déclenchez votre action sur un événement spécifique dans ce flux." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, une nouvelle version améliorée de Actions, est désormais disponible. La version actuelle reste accessible, mais notre développement futur se concentrera sur la nouvelle, qui finira par remplacer la version actuelle." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Appliquer" }, "ACTIONSTWO": { + "BETA_NOTE": "Vous utilisez actuellement la nouvelle version Actions V2, qui est en phase bêta. L'ancienne version 1 est toujours disponible mais sera arrêtée à l'avenir. Veuillez signaler tout problème ou commentaire.", "EXECUTION": { "TITLE": "Actions", "DESCRIPTION": "Les actions vous permettent d'exécuter du code personnalisé en réponse à des requêtes API, des événements ou des fonctions spécifiques. Utilisez-les pour étendre Zitadel, automatiser les flux de travail et vous intégrer à d'autres systèmes.", @@ -619,6 +621,7 @@ "restCall": "Appel REST", "restAsync": "REST Asynchrone" }, + "TYPES_DESCRIPTION": "Webhook, l'appel gère le code d'état mais la réponse est sans importance\nCall, l'appel gère le code d'état et la réponse\nAsync, l'appel ne gère ni le code d'état ni la réponse, mais peut être appelé en parallèle avec d'autres cibles", "ENDPOINT": "Point de terminaison", "ENDPOINT_DESCRIPTION": "Entrez le point de terminaison où votre code est hébergé. Assurez-vous qu'il nous est accessible !", "TIMEOUT": "Délai d'attente", @@ -1508,7 +1511,8 @@ "APPEARANCE": "Apparence", "OTHER": "Autres", "STORAGE": "Stockage" - } + }, + "BETA": "BÊTA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 5724c45a51..d46bc96153 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Folyamatok", "DESCRIPTION": "Válassz egy hitelesítési folyamatot, és váltasd ki a műveletedet egy adott esemény bekövetkezésekor ebben a folyamatban." - } + }, + "ACTIONSTWO_NOTE": "Az Actions V2, az Actions új, továbbfejlesztett verziója mostantól elérhető. A jelenlegi verzió továbbra is elérhető, de a jövőbeli fejlesztéseink az új verzióra összpontosítanak, amely végül felváltja a jelenlegi verziót." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Alkalmaz" }, "ACTIONSTWO": { + "BETA_NOTE": "Jelenleg az új Actions V2-t használja, amely béta verzióban van. Az előző 1-es verzió továbbra is elérhető, de a jövőben megszűnik. Kérjük, jelezze az esetleges problémákat vagy visszajelzéseit.", "EXECUTION": { "TITLE": "Műveletek", "DESCRIPTION": "A műveletek lehetővé teszik egyedi kód futtatását API-kérésekre, eseményekre vagy konkrét függvényekre válaszul. Használja őket a Zitadel kiterjesztéséhez, a munkafolyamatok automatizálásához és más rendszerekkel való integrációhoz.", @@ -619,6 +621,7 @@ "restCall": "REST Hívás", "restAsync": "REST Aszinkron" }, + "TYPES_DESCRIPTION": "Webhook, a hívás kezeli az állapotkódot, de a válasz lényegtelen\nCall, a hívás kezeli az állapotkódot és a választ\nAsync, a hívás sem az állapotkódot, sem a választ nem kezeli, de párhuzamosan hívható más célokkal", "ENDPOINT": "Végpont", "ENDPOINT_DESCRIPTION": "Adja meg azt a végpontot, ahol a kódja található. Győződjön meg arról, hogy elérhető számunkra!", "TIMEOUT": "Időtúllépés", @@ -1508,7 +1511,8 @@ "APPEARANCE": "Megjelenés", "OTHER": "Egyéb", "STORAGE": "Tárolás" - } + }, + "BETA": "BÉTA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 77584e883b..f8831a224d 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -69,7 +69,8 @@ "FLOWS": { "TITLE": "Mengalir", "DESCRIPTION": "Pilih alur autentikasi dan picu tindakan Anda pada peristiwa tertentu dalam alur ini." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, versi baru dan lebih baik dari Actions, sekarang tersedia. Versi saat ini masih dapat diakses, tetapi pengembangan di masa depan akan difokuskan pada versi baru ini yang pada akhirnya akan menggantikan versi saat ini." }, "SETTINGS": { "INSTANCE": { @@ -496,6 +497,7 @@ "APPLY": "Menerapkan" }, "ACTIONSTWO": { + "BETA_NOTE": "Anda saat ini menggunakan Actions V2 baru, yang masih dalam versi beta. Versi sebelumnya, Versi 1, masih tersedia tetapi akan dihentikan di masa depan. Silakan laporkan masalah atau berikan masukan.", "EXECUTION": { "TITLE": "Tindakan", "DESCRIPTION": "Tindakan memungkinkan Anda menjalankan kode khusus sebagai respons terhadap permintaan API, peristiwa, atau fungsi tertentu. Gunakan ini untuk memperluas Zitadel, mengotomatiskan alur kerja, dan berintegrasi dengan sistem lain.", @@ -586,6 +588,7 @@ "restCall": "Panggilan REST", "restAsync": "REST Asinkron" }, + "TYPES_DESCRIPTION": "Webhook, panggilan menangani kode status tetapi respons tidak relevan\nCall, panggilan menangani kode status dan respons\nAsync, panggilan tidak menangani kode status maupun respons, tetapi dapat dipanggil secara paralel dengan Target lain", "ENDPOINT": "Titik Akhir", "ENDPOINT_DESCRIPTION": "Masukkan titik akhir tempat kode Anda dihosting. Pastikan dapat diakses oleh kami!", "TIMEOUT": "Batas Waktu", @@ -1386,7 +1389,8 @@ "APPEARANCE": "Penampilan", "OTHER": "Lainnya", "STORAGE": "Penyimpanan" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index b01762b175..5dad683bca 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Flussi", "DESCRIPTION": "Scegli un flusso di autenticazione e attiva la tua azione su un evento specifico all'interno di questo flusso." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, una nuova versione migliorata di Actions, è ora disponibile. La versione attuale è ancora accessibile, ma i futuri sviluppi si concentreranno su quella nuova, che alla fine sostituirà la versione corrente." }, "SETTINGS": { "INSTANCE": { @@ -528,6 +529,7 @@ "APPLY": "Applicare" }, "ACTIONSTWO": { + "BETA_NOTE": "Stai attualmente utilizzando la nuova versione Actions V2, che è in beta. La precedente Versione 1 è ancora disponibile, ma sarà dismessa in futuro. Ti preghiamo di segnalare eventuali problemi o feedback.", "EXECUTION": { "TITLE": "Azioni", "DESCRIPTION": "Le azioni consentono di eseguire codice personalizzato in risposta a richieste API, eventi o funzioni specifiche. Usale per estendere Zitadel, automatizzare i flussi di lavoro e integrarti con altri sistemi.", @@ -618,6 +620,7 @@ "restCall": "Chiamata REST", "restAsync": "REST Asincrono" }, + "TYPES_DESCRIPTION": "Webhook, la chiamata gestisce il codice di stato ma la risposta è irrilevante\nCall, la chiamata gestisce il codice di stato e la risposta\nAsync, la chiamata non gestisce né il codice di stato né la risposta, ma può essere eseguita in parallelo con altri obiettivi", "ENDPOINT": "Endpoint", "ENDPOINT_DESCRIPTION": "Inserisci l'endpoint in cui è ospitato il tuo codice. Assicurati che sia accessibile per noi!", "TIMEOUT": "Timeout", @@ -1508,7 +1511,8 @@ "APPEARANCE": "Aspetto", "OTHER": "Altro", "STORAGE": "Dati" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index c0429ec816..f09dfeb564 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "フロー", "DESCRIPTION": "認証フローを選択し、そのフロー内の特定のイベントでアクションをトリガーします。" - } + }, + "ACTIONSTWO_NOTE": "Actions V2(アクションズV2)、改善された新しいバージョンが利用可能になりました。現在のバージョンも引き続き利用可能ですが、今後の開発は新バージョンに集中し、最終的には現在のバージョンを置き換える予定です。" }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "アプライ" }, "ACTIONSTWO": { + "BETA_NOTE": "現在、新しいActions V2(ベータ版)を使用しています。以前のバージョン1はまだ利用可能ですが、今後廃止される予定です。問題やフィードバックがあればお知らせください。", "EXECUTION": { "TITLE": "アクション", "DESCRIPTION": "アクションを使用すると、APIリクエスト、イベント、または特定の関数に応答してカスタムコードを実行できます。これらを使用して、Zitadelを拡張し、ワークフローを自動化し、他のシステムと統合します。", @@ -619,6 +621,7 @@ "restCall": "REST 呼び出し", "restAsync": "REST 非同期" }, + "TYPES_DESCRIPTION": "Webhook、呼び出しはステータスコードを処理しますが、応答は無関係です\nCall、呼び出しはステータスコードと応答を処理します\nAsync、呼び出しはステータスコードも応答も処理しませんが、他のターゲットと並行して呼び出すことができます", "ENDPOINT": "エンドポイント", "ENDPOINT_DESCRIPTION": "コードがホストされているエンドポイントを入力します。アクセス可能であることを確認してください。", "TIMEOUT": "タイムアウト", @@ -1508,7 +1511,8 @@ "APPEARANCE": "設定", "OTHER": "その他", "STORAGE": "ストレージ" - } + }, + "BETA": "ベータ" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index b791234cd5..304f52e127 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "플로우", "DESCRIPTION": "인증 플로우를 선택하고 이 플로우 내의 특정 이벤트에서 작업을 트리거하세요." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, 개선된 새로운 버전이 출시되었습니다. 현재 버전은 여전히 접근할 수 있지만, 앞으로의 개발은 새로운 버전에 집중될 것이며, 결국 현재 버전을 대체할 것입니다." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "적용" }, "ACTIONSTWO": { + "BETA_NOTE": "현재 베타 버전인 새로운 Actions V2를 사용하고 있습니다. 이전 버전 1은 여전히 사용 가능하지만, 향후 중단될 예정입니다. 문제나 피드백이 있으면 알려주세요.", "EXECUTION": { "TITLE": "작업", "DESCRIPTION": "작업을 통해 API 요청, 이벤트 또는 특정 함수에 대한 응답으로 사용자 지정 코드를 실행할 수 있습니다. 이를 사용하여 Zitadel을 확장하고 워크플로를 자동화하며 다른 시스템과 통합합니다.", @@ -619,6 +621,7 @@ "restCall": "REST 호출", "restAsync": "REST 비동기" }, + "TYPES_DESCRIPTION": "Webhook, 호출은 상태 코드를 처리하지만 응답은 중요하지 않습니다\nCall, 호출은 상태 코드와 응답을 처리합니다\nAsync, 호출은 상태 코드나 응답을 처리하지 않지만 다른 대상과 병렬로 호출할 수 있습니다", "ENDPOINT": "엔드포인트", "ENDPOINT_DESCRIPTION": "코드가 호스팅되는 엔드포인트를 입력하십시오. 우리에게 액세스할 수 있는지 확인하십시오!", "TIMEOUT": "시간 초과", @@ -1508,7 +1511,8 @@ "APPEARANCE": "외형", "OTHER": "기타", "STORAGE": "저장소" - } + }, + "BETA": "베타" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 22e0e6d3d7..1ab4dce534 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Текови", "DESCRIPTION": "Изберете тек на автентификација и активирајте ја вашата акција на специфичен настан во тој тек." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, нова и подобрена верзија на Actions, сега е достапна. Сегашната верзија сè уште е достапна, но идниот развој ќе биде насочен кон новата верзија, која на крајот ќе ја замени сегашната." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Пријавете се" }, "ACTIONSTWO": { + "BETA_NOTE": "", "EXECUTION": { "TITLE": "Акции", "DESCRIPTION": "Акциите ви овозможуваат да извршувате прилагоден код како одговор на API барања, настани или специфични функции. Користете ги за да го проширите Zitadel, да ги автоматизирате работните процеси и да се интегрирате со други системи.", @@ -619,6 +621,7 @@ "restCall": "REST Повик", "restAsync": "REST Асинхроно" }, + "TYPES_DESCRIPTION": "Webhook, повикот го обработува статусниот код но одговорот е ирелевантен\nCall, повикот го обработува статусниот код и одговорот\nAsync, повикот не го обработува ниту статусниот код ниту одговорот, но може да се повика паралелно со други цели", "ENDPOINT": "Крајна точка", "ENDPOINT_DESCRIPTION": "Внесете ја крајната точка каде што е хостиран вашиот код. Осигурете се дека е достапна за нас!", "TIMEOUT": "Време на истекување", @@ -1509,7 +1512,8 @@ "APPEARANCE": "Изглед", "OTHER": "Друго", "STORAGE": "складирање" - } + }, + "BETA": "БЕТА" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 112474e770..a08f62ed20 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Stromen", "DESCRIPTION": "Kies een authenticatiestroom en activeer je actie bij een specifieke gebeurtenis binnen deze stroom." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, een nieuwe en verbeterde versie van Actions, is nu beschikbaar. De huidige versie blijft toegankelijk, maar onze toekomstige ontwikkeling zal zich richten op de nieuwe versie, die uiteindelijk de huidige zal vervangen." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Toepassen" }, "ACTIONSTWO": { + "BETA_NOTE": "U gebruikt momenteel de nieuwe Actions V2, die zich in de bètaversie bevindt. De vorige versie 1 is nog beschikbaar maar zal in de toekomst worden stopgezet. Meld alstublieft eventuele problemen of feedback.", "EXECUTION": { "TITLE": "Acties", "DESCRIPTION": "Met acties kunt u aangepaste code uitvoeren als reactie op API-verzoeken, gebeurtenissen of specifieke functies. Gebruik ze om Zitadel uit te breiden, workflows te automatiseren en te integreren met andere systemen.", @@ -619,6 +621,7 @@ "restCall": "REST Aanroep", "restAsync": "REST Asynchroon" }, + "TYPES_DESCRIPTION": "Webhook, de oproep verwerkt de statuscode maar de reactie is irrelevant\nCall, de oproep verwerkt de statuscode en de reactie\nAsync, de oproep verwerkt noch de statuscode noch de reactie, maar kan parallel aan andere doelen worden aangeroepen", "ENDPOINT": "Eindpunt", "ENDPOINT_DESCRIPTION": "Voer het eindpunt in waar uw code wordt gehost. Zorg ervoor dat het voor ons toegankelijk is!", "TIMEOUT": "Time-out", @@ -1508,7 +1511,8 @@ "APPEARANCE": "Verschijning", "OTHER": "Andere", "STORAGE": "opslag" - } + }, + "BETA": "BÈTA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 3244ccb4a6..3902378af6 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Przepływy", "DESCRIPTION": "Wybierz przepływ uwierzytelniania i wywołaj swoją akcję przy określonym zdarzeniu w tym przepływie." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, nowa, ulepszona wersja Actions, jest już dostępna. Obecna wersja jest nadal dostępna, ale przyszły rozwój będzie skoncentrowany na nowej wersji, która ostatecznie zastąpi obecną." }, "SETTINGS": { "INSTANCE": { @@ -528,6 +529,7 @@ "APPLY": "Stosować" }, "ACTIONSTWO": { + "BETA_NOTE": "Obecnie korzystasz z nowej wersji Actions V2, która jest w fazie beta. Poprzednia wersja 1 jest nadal dostępna, ale w przyszłości zostanie wycofana. Prosimy o zgłaszanie wszelkich problemów lub opinii.", "EXECUTION": { "TITLE": "Akcje", "DESCRIPTION": "Akcje umożliwiają uruchamianie niestandardowego kodu w odpowiedzi na żądania API, zdarzenia lub określone funkcje. Użyj ich, aby rozszerzyć Zitadel, zautomatyzować przepływy pracy i zintegrować się z innymi systemami.", @@ -618,6 +620,7 @@ "restCall": "Wywołanie REST", "restAsync": "REST Asynchroniczny" }, + "TYPES_DESCRIPTION": "Webhook, wywołanie obsługuje kod stanu, ale odpowiedź jest nieistotna\nCall, wywołanie obsługuje kod stanu i odpowiedź\nAsync, wywołanie nie obsługuje ani kodu stanu, ani odpowiedzi, ale może być wywoływane równolegle z innymi celami", "ENDPOINT": "Punkt końcowy", "ENDPOINT_DESCRIPTION": "Wprowadź punkt końcowy, w którym hostowany jest Twój kod. Upewnij się, że jest dla nas dostępny!", "TIMEOUT": "Limit czasu", @@ -1507,7 +1510,8 @@ "APPEARANCE": "Wygląd", "OTHER": "Inne", "STORAGE": "składowanie" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 30b0f1d4e8..016785f2c8 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Fluxos", "DESCRIPTION": "Escolha um fluxo de autenticação e acione sua ação em um evento específico dentro desse fluxo." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, uma nova e melhorada versão de Actions, já está disponível. A versão atual ainda é acessível, mas o nosso desenvolvimento futuro se concentrará na nova versão, que acabará por substituir a atual." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Aplicar" }, "ACTIONSTWO": { + "BETA_NOTE": "Você está atualmente usando a nova Actions V2, que está em versão beta. A versão anterior 1 ainda está disponível, mas será descontinuada no futuro. Por favor, reporte quaisquer problemas ou envie feedback.", "EXECUTION": { "TITLE": "Ações", "DESCRIPTION": "As ações permitem que você execute código personalizado em resposta a solicitações de API, eventos ou funções específicas. Use-as para estender o Zitadel, automatizar fluxos de trabalho e integrar-se a outros sistemas.", @@ -619,6 +621,7 @@ "restCall": "Chamada REST", "restAsync": "REST Assíncrono" }, + "TYPES_DESCRIPTION": "Webhook, a chamada lida com o código de status, mas a resposta é irrelevante\nCall, a chamada lida com o código de status e a resposta\nAsync, a chamada não lida nem com o código de status nem com a resposta, mas pode ser chamada em paralelo com outros alvos", "ENDPOINT": "Ponto de Extremidade", "ENDPOINT_DESCRIPTION": "Insira o ponto de extremidade onde seu código está hospedado. Certifique-se de que ele esteja acessível para nós!", "TIMEOUT": "Tempo Limite", @@ -1509,7 +1512,8 @@ "APPEARANCE": "Aparência", "OTHER": "Outro", "STORAGE": "armazenar" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index 6c73852beb..4ad511c466 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Fluxuri", "DESCRIPTION": "Alegeți un flux de autentificare și declanșați acțiunea dvs. la un anumit eveniment din cadrul acestui flux." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, o nouă versiune îmbunătățită a Actions, este acum disponibilă. Versiunea actuală este încă accesibilă, dar dezvoltarea viitoare se va concentra pe cea nouă, care în cele din urmă va înlocui versiunea actuală." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Aplicați" }, "ACTIONSTWO": { + "BETA_NOTE": "În prezent utilizați noua versiune Actions V2, care este în faza beta. Versiunea anterioară 1 este încă disponibilă, dar va fi întreruptă în viitor. Vă rugăm să raportați orice problemă sau feedback.", "EXECUTION": { "TITLE": "Acțiuni", "DESCRIPTION": "Acțiunile vă permit să rulați cod personalizat ca răspuns la cereri API, evenimente sau funcții specifice. Folosiți-le pentru a extinde Zitadel, a automatiza fluxurile de lucru și a vă integra cu alte sisteme.", @@ -619,6 +621,7 @@ "restCall": "Apel REST", "restAsync": "REST Asincron" }, + "TYPES_DESCRIPTION": "Webhook, apelul gestionează codul de stare, dar răspunsul este irelevant\nCall, apelul gestionează codul de stare și răspunsul\nAsync, apelul nu gestionează nici codul de stare, nici răspunsul, dar poate fi apelat în paralel cu alte Ținte", "ENDPOINT": "Punct Final", "ENDPOINT_DESCRIPTION": "Introduceți punctul final unde este găzduit codul dvs. Asigurați-vă că este accesibil pentru noi!", "TIMEOUT": "Timeout", @@ -1506,7 +1509,8 @@ "APPEARANCE": "Aspect", "OTHER": "Altele", "STORAGE": "Stocare" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 6e2d7df7b4..43bc266be0 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Потоки", "DESCRIPTION": "Выберите поток аутентификации и активируйте ваше действие на определенном событии в этом потоке." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, новая и улучшенная версия Actions, теперь доступна. Текущая версия всё ещё доступна, но дальнейшая разработка будет сосредоточена на новой версии, которая в конечном итоге заменит текущую." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Применять" }, "ACTIONSTWO": { + "BETA_NOTE": "Вы используете новую версию Actions V2, которая находится в бета-тестировании. Предыдущая версия 1 всё ещё доступна, но будет отключена в будущем. Пожалуйста, сообщайте о любых проблемах или отправляйте отзывы.", "EXECUTION": { "TITLE": "Действия", "DESCRIPTION": "Действия позволяют запускать пользовательский код в ответ на API-запросы, события или определенные функции. Используйте их для расширения Zitadel, автоматизации рабочих процессов и интеграции с другими системами.", @@ -619,6 +621,7 @@ "restCall": "REST Вызов", "restAsync": "REST Асинхронный" }, + "TYPES_DESCRIPTION": "Webhook, вызов обрабатывает код состояния, но ответ не имеет значения\nCall, вызов обрабатывает код состояния и ответ\nAsync, вызов не обрабатывает ни код состояния, ни ответ, но может выполняться параллельно с другими целями", "ENDPOINT": "Конечная точка", "ENDPOINT_DESCRIPTION": "Введите конечную точку, где размещен ваш код. Убедитесь, что он доступен для нас!", "TIMEOUT": "Тайм-аут", @@ -1553,7 +1556,8 @@ "APPEARANCE": "Вид", "OTHER": "Другое", "STORAGE": "хранилище" - } + }, + "BETA": "БЕТА" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index e747571f7a..00b7854603 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "Flöden", "DESCRIPTION": "Välj ett autentiseringsflöde och trigga din åtgärd vid en specifik händelse inom detta flöde." - } + }, + "ACTIONSTWO_NOTE": "Actions V2, en ny och förbättrad version av Actions, är nu tillgänglig. Den nuvarande versionen är fortfarande tillgänglig, men framtida utveckling kommer att fokusera på den nya, som så småningom kommer att ersätta den nuvarande." }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "Tillämpa" }, "ACTIONSTWO": { + "BETA_NOTE": "Du använder för närvarande nya Actions V2, som är i betaversion. Den tidigare versionen 1 är fortfarande tillgänglig men kommer att avvecklas i framtiden. Vänligen rapportera eventuella problem eller ge feedback.", "EXECUTION": { "TITLE": "Åtgärder", "DESCRIPTION": "Åtgärder låter dig köra anpassad kod som svar på API-förfrågningar, händelser eller specifika funktioner. Använd dem för att utöka Zitadel, automatisera arbetsflöden och integrera med andra system.", @@ -619,6 +621,7 @@ "restCall": "REST Anrop", "restAsync": "REST Asynkron" }, + "TYPES_DESCRIPTION": "Webhook, anropet hanterar statuskoden men svaret är irrelevant\nCall, anropet hanterar statuskoden och svaret\nAsync, anropet hanterar varken statuskod eller svar men kan anropas parallellt med andra mål", "ENDPOINT": "Slutpunkt", "ENDPOINT_DESCRIPTION": "Ange slutpunkten där din kod finns. Se till att den är tillgänglig för oss!", "TIMEOUT": "Tidsgräns", @@ -1512,7 +1515,8 @@ "APPEARANCE": "Utseende", "OTHER": "Övrigt", "STORAGE": "Lagring" - } + }, + "BETA": "BETA" }, "SETTING": { "LANGUAGES": { diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 945e4200ef..496b3d528e 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -75,7 +75,8 @@ "FLOWS": { "TITLE": "流程", "DESCRIPTION": "选择一个认证流程,并在该流程中的特定事件上触发您的操作。" - } + }, + "ACTIONSTWO_NOTE": "Actions V2,一个全新改进版的Actions,现在已上线。目前版本仍可使用,但未来开发将专注于新版本,最终将取代当前版本。" }, "SETTINGS": { "INSTANCE": { @@ -529,6 +530,7 @@ "APPLY": "申请" }, "ACTIONSTWO": { + "BETA_NOTE": "您目前正在使用新的 Actions V2(测试版)。之前的版本1仍可使用,但未来将停止支持。请报告任何问题或反馈意见。", "EXECUTION": { "TITLE": "操作", "DESCRIPTION": "操作允许您运行自定义代码以响应 API 请求、事件或特定函数。使用它们来扩展 Zitadel、自动化工作流程并与其他系统集成。", @@ -619,6 +621,7 @@ "restCall": "REST 调用", "restAsync": "REST 异步" }, + "TYPES_DESCRIPTION": "Webhook,调用处理状态码但响应无关紧要\nCall,调用处理状态码和响应\nAsync,调用既不处理状态码也不处理响应,但可以与其他目标并行调用", "ENDPOINT": "端点", "ENDPOINT_DESCRIPTION": "输入您的代码托管的端点。确保我们可以访问它!", "TIMEOUT": "超时", @@ -1508,7 +1511,8 @@ "APPEARANCE": "外观", "OTHER": "其他", "STORAGE": "贮存" - } + }, + "BETA": "测试版" }, "SETTING": { "LANGUAGES": { diff --git a/console/yarn.lock b/console/yarn.lock index 5dd602dfa8..2e586abedb 100644 --- a/console/yarn.lock +++ b/console/yarn.lock @@ -3452,29 +3452,22 @@ js-yaml "^3.10.0" tslib "^2.4.0" -"@zitadel/client@^1.0.7": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@zitadel/client/-/client-1.0.7.tgz#39dc8d3d10bfa01e5cf56205ba188f79c39f052d" - integrity sha512-sZG4NEa8vQBt3+4W1AesY+5DstDBuZiqGH2EM+UqbO5D93dlDZInXqZ5oRE7RSl2Bk5ED9mbMFrB7b8DuRw72A== +"@zitadel/client@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@zitadel/client/-/client-1.2.0.tgz#8cdc3090f75fcf3a78c4f0266d3c56a0cca6821a" + integrity sha512-Q20nXhKD7VDb8D1UxhDxubC70GFrSPckrJviPR/rAfRR5slUIRTk3AvDS6Q1WvUn4Xtt+btnq52Z5O8lZtVG0w== dependencies: "@bufbuild/protobuf" "^2.2.2" "@connectrpc/connect" "^2.0.0" "@connectrpc/connect-node" "^2.0.0" "@connectrpc/connect-web" "^2.0.0" - "@zitadel/proto" "1.0.4" + "@zitadel/proto" "1.2.0" jose "^5.3.0" -"@zitadel/proto@1.0.4": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@zitadel/proto/-/proto-1.0.4.tgz#e2fe9895f2960643c3619191255aa2f4913ad873" - integrity sha512-s13ZMhuOTe0b+geV+JgJud+kpYdq7TgkuCe7RIY+q4Xs5KC0FHMKfvbAk/jpFbD+TSQHiwo/TBNZlGHdwUR9Ig== - dependencies: - "@bufbuild/protobuf" "^2.2.2" - -"@zitadel/proto@1.0.5-sha-4118a9d": - version "1.0.5-sha-4118a9d" - resolved "https://registry.yarnpkg.com/@zitadel/proto/-/proto-1.0.5-sha-4118a9d.tgz#e09025f31b2992b061d5416a0d1e12ef370118cc" - integrity sha512-7ZFwISL7TqdCkfEUx7/H6UJDqX8ZP2jqG1ulbELvEQ2smrK365Zs7AkJGeB/xbVdhQW9BOhWy2R+Jni7sfxd2w== +"@zitadel/proto@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@zitadel/proto/-/proto-1.2.0.tgz#9b9a40defcd9e8464627cc99ac3fd7bcf8994ffd" + integrity sha512-OqHgyCnD9l950xswdVNPIsLA01qSpOPf+0bYqYJWHafytIBbvGNJRnypu4X0LnaFXLM6LakkP4pWYeiGLmwxaw== dependencies: "@bufbuild/protobuf" "^2.2.2" From c36b0ab2e2a0a2632cffae4fe6ac086a126bc2a8 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Tue, 29 Apr 2025 16:12:34 +0200 Subject: [PATCH 025/123] docs(self-hosting): add login to lb example (#9496) # Which Problems Are Solved We have no docs for self-hosting the login using the standard login as a standalone docker container. # How the Problems Are Solved A common self-hosting case is to publish the login at the same domain as Zitadel behind a reverse proxy. That's why we extend the load balancing example. We refocus the example from *making TLS work* to *running multiple services behind the proxy and connect them using an internal network and DNS*. I decided this together with @fforootd. For authenticating with the login application, we have to set up a service user and give it the role IAM_LOGIN_CLIENT. We do so in the use-new-login "job" container as `zitadel setup` only supports Zitadel users with the role IAM_ADMIN AFAIR. The login application relies on a healthy Zitadel API on startup, which is why we fix the containers readiness reports. # Additional Changes - We deploy the init and setup jobs independently, because this better reflects our production recommendatinons. It gives more control over the upgrade process. - We use the ExternalDomain *127.0.0.1.sslip.io* instead of *my.domain*, because this doesn't require changing the local DNS resolution by changing */etc/hosts* for local tests. # Testing The commands in the preview docs use to the configuration files on main. This is fine when the PR is merged but not for testing the PR. Replace the used links to make them point to the PRs changed files. Instead of the commands in the preview docs, use these: ```bash # Download the docker compose example configuration. wget https://raw.githubusercontent.com/zitadel/zitadel/refs/heads/docs-compose-login/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml # Download the Traefik example configuration. wget https://raw.githubusercontent.com/zitadel/zitadel/refs/heads/docs-compose-login/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml # Download and adjust the example configuration file containing standard configuration. wget https://raw.githubusercontent.com/zitadel/zitadel/refs/heads/docs-compose-login/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml # Download and adjust the example configuration file containing secret configuration. wget https://raw.githubusercontent.com/zitadel/zitadel/refs/heads/docs-compose-login/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-secrets.yaml # Download and adjust the example configuration file containing database initialization configuration. wget https://raw.githubusercontent.com/zitadel/zitadel/refs/heads/docs-compose-login/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml # A single ZITADEL instance always needs the same 32 bytes long masterkey # Generate one to a file if you haven't done so already and pass it as environment variable LC_ALL=C tr -dc '[:graph:]' ./zitadel-masterkey export ZITADEL_MASTERKEY="$(cat ./zitadel-masterkey)" # Run the database and application containers docker compose up --detach --wait ``` # Additional Context - Closes https://github.com/zitadel/DevOps/issues/111 - Depends on https://github.com/zitadel/typescript/pull/412 - Contributes to road map item https://github.com/zitadel/zitadel/issues/9481 --- .../deploy/loadbalancing-example/.gitignore | 1 + .../loadbalancing-example/docker-compose.yaml | 153 +++++++++++++++--- .../example-traefik.yaml | 57 ++----- .../example-zitadel-config.yaml | 31 ++-- .../example-zitadel-init-steps.yaml | 12 +- .../loadbalancing-example.mdx | 35 ++-- 6 files changed, 183 insertions(+), 106 deletions(-) create mode 100644 docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore b/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore new file mode 100644 index 0000000000..bd98bacd66 --- /dev/null +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore @@ -0,0 +1 @@ +.env-file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index d1d8c95bb2..013fc2aa22 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -1,48 +1,157 @@ services: - traefik: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + - POSTGRES_USER=root + - POSTGRES_PASSWORD=postgres networks: - - 'zitadel' - image: "traefik:latest" - ports: - - "80:80" - - "443:443" + - 'storage' + healthcheck: + test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"] + interval: 10s + timeout: 60s + retries: 5 + start_period: 10s volumes: - - "./example-traefik.yaml:/etc/traefik/traefik.yaml" + - 'data:/var/lib/postgresql/data:rw' - zitadel: - restart: 'always' + zitadel-init: + restart: 'no' networks: - - 'zitadel' + - 'storage' image: 'ghcr.io/zitadel/zitadel:latest' - command: 'start-from-init --config /example-zitadel-config.yaml --config /example-zitadel-secrets.yaml --steps /example-zitadel-init-steps.yaml --masterkey "${ZITADEL_MASTERKEY}" --tlsMode external' + command: 'init --config /example-zitadel-config.yaml --config /example-zitadel-secrets.yaml' depends_on: db: condition: 'service_healthy' volumes: - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' + + zitadel-setup: + restart: 'no' + networks: + - 'storage' + # We use the debug image so we have the environment to + # - create the .env file for the login to authenticate at Zitadel + # - set the correct permissions for the .env-file folder + image: 'ghcr.io/zitadel/zitadel:latest-debug' + user: root + entrypoint: '/bin/sh' + command: + - -c + - > + /app/zitadel setup + --config /example-zitadel-config.yaml + --config /example-zitadel-secrets.yaml + --steps /example-zitadel-init-steps.yaml + --masterkey ${ZITADEL_MASTERKEY} && + mv /pat /.env-file/pat || exit 0 && + echo ZITADEL_SERVICE_USER_TOKEN=$(cat /.env-file/pat) > /.env-file/.env && + chown -R 1001:${GID} /.env-file && + chmod -R 770 /.env-file + environment: + - GID + depends_on: + zitadel-init: + condition: 'service_completed_successfully' + restart: false + volumes: + - './.env-file:/.env-file:rw' + - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' + - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' - './example-zitadel-init-steps.yaml:/example-zitadel-init-steps.yaml:ro' - db: - image: postgres:17-alpine - restart: always - environment: - - POSTGRES_USER=root - - POSTGRES_PASSWORD=postgres + zitadel: + restart: 'unless-stopped' networks: - - 'zitadel' + - 'backend' + - 'storage' + image: 'ghcr.io/zitadel/zitadel:latest' + command: > + start --config /example-zitadel-config.yaml + --config /example-zitadel-secrets.yaml + --masterkey ${ZITADEL_MASTERKEY} + depends_on: + zitadel-setup: + condition: 'service_completed_successfully' + restart: true + volumes: + - './example-zitadel-config.yaml:/example-zitadel-config.yaml:ro' + - './example-zitadel-secrets.yaml:/example-zitadel-secrets.yaml:ro' + ports: + - "8080:8080" healthcheck: - test: ["CMD-SHELL", "pg_isready", "-d", "db_prod"] + test: [ + "CMD", "/app/zitadel", "ready", + "--config", "/example-zitadel-config.yaml", + "--config", "/example-zitadel-secrets.yaml" + ] interval: 10s timeout: 60s retries: 5 - start_period: 10s + start_period: 10s + + # The use-new-login service configures Zitadel to use the new login v2 for all applications. + # It also gives the setupped machine user the necessary IAM_LOGIN_CLIENT role. + use-new-login: + restart: 'on-failure' + user: "1001" + networks: + - 'backend' + image: 'badouralix/curl-jq:alpine' + entrypoint: '/bin/sh' + command: + - -c + - > + curl -X PUT -H "Host: 127.0.0.1.sslip.io" -H "Authorization: Bearer $(cat ./.env-file/pat)" --insecure http://zitadel:8080/v2/features/instance -d '{"loginV2": {"required": true}}' && + LOGIN_USER=$(curl --fail-with-body -H "Host: 127.0.0.1.sslip.io" -H "Authorization: Bearer $(cat ./.env-file/pat)" --insecure http://zitadel:8080/auth/v1/users/me | jq -r '.user.id') && + curl -X PUT -H "Host: 127.0.0.1.sslip.io" -H "Authorization: Bearer $(cat ./.env-file/pat)" --insecure http://zitadel:8080/admin/v1/members/$${LOGIN_USER} -d '{"roles": ["IAM_OWNER", "IAM_LOGIN_CLIENT"]}' volumes: - - 'data:/var/lib/postgresql/data:rw' + - './.env-file:/.env-file:ro' + depends_on: + zitadel: + condition: 'service_healthy' + restart: false + + login: + restart: 'unless-stopped' + networks: + - 'backend' + image: 'ghcr.io/zitadel/login:main' + environment: + - ZITADEL_API_URL=http://zitadel:8080 + - CUSTOM_REQUEST_HEADERS=Host:127.0.0.1.sslip.io + - NEXT_PUBLIC_BASE_PATH="/ui/v2/login" + user: "${UID:-1000}" + volumes: + - './.env-file:/.env-file:ro' + depends_on: + zitadel: + condition: 'service_healthy' + restart: false + + traefik: + restart: 'unless-stopped' + networks: + - 'backend' + image: "traefik:latest" + ports: + - "80:80" + - "443:443" + volumes: + - "./example-traefik.yaml:/etc/traefik/traefik.yaml" + depends_on: + zitadel: + condition: 'service_healthy' + login: + condition: 'service_started' networks: - zitadel: + storage: + backend: volumes: data: diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml index c16f74a46d..a3af425172 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-traefik.yaml @@ -4,66 +4,37 @@ log: accessLog: {} entrypoints: - web: - address: ":80" - websecure: address: ":443" -tls: - stores: - default: - # generates self-signed certificates - defaultCertificate: - providers: file: filename: /etc/traefik/traefik.yaml http: - middlewares: - zitadel: - headers: - isDevelopment: false - allowedHosts: - - 'my.domain' - customRequestHeaders: - authority: 'my.domain' - redirect-to-https: - redirectScheme: - scheme: https - port: 443 - permanent: true - routers: - # Redirect HTTP to HTTPS - router0: + login: entryPoints: - - web - middlewares: - - redirect-to-https - rule: 'HostRegexp(`my.domain`, `{subdomain:[a-z]+}.my.domain`)' - service: zitadel - # The actual ZITADEL router - router1: + - websecure + service: login + rule: 'Host(`127.0.0.1.sslip.io`) && PathPrefix(`/ui/v2/login`)' + tls: {} + zitadel: entryPoints: - websecure service: zitadel - middlewares: - - zitadel - rule: 'HostRegexp(`my.domain`, `{subdomain:[a-z]+}.my.domain`)' - tls: - domains: - - main: "my.domain" - sans: - - "*.my.domain" - - "my.domain" + rule: 'Host(`127.0.0.1.sslip.io`) && !PathPrefix(`/ui/v2/login`)' + tls: {} - # Add the service services: + login: + loadBalancer: + servers: + - url: http://login:3000 + passHostHeader: true zitadel: loadBalancer: servers: - # h2c is the scheme for unencrypted HTTP/2 - url: h2c://zitadel:8080 passHostHeader: true + diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml index 392bf1148e..fadd39373d 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml @@ -1,26 +1,29 @@ # All possible options and their defaults: https://github.com/zitadel/zitadel/blob/main/cmd/defaults.yaml -Log: - Level: 'info' -# Make ZITADEL accessible over HTTPs, not HTTP ExternalSecure: true -ExternalDomain: my.domain +ExternalDomain: 127.0.0.1.sslip.io ExternalPort: 443 +# Traefik terminates TLS. Inside the Docker network, we use plain text. +TLS.Enabled: false + # If not using the docker compose example, adjust these values for connecting ZITADEL to your PostgreSQL Database: postgres: Host: 'db' Port: 5432 Database: zitadel - User: - SSL: - Mode: 'disable' - Admin: - SSL: - Mode: 'disable' + User.SSL.Mode: 'disable' + Admin.SSL.Mode: 'disable' -LogStore: - Access: - Stdout: - Enabled: true +# By default, ZITADEL should redirect to /ui/v2/login +OIDC: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 +SAML.DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 + +# Access logs allow us to debug Network issues +LogStore.Access.Stdout.Enabled: true + +# Skipping the MFA init step allows us to immediately authenticate at the console +DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml index 804e3d18d8..be63164ced 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml @@ -1,8 +1,12 @@ # All possible options and their defaults: https://github.com/zitadel/zitadel/blob/main/cmd/setup/steps.yaml FirstInstance: + PatPath: '/pat' Org: - Name: 'My Org' + # We want to authenticate immediately at the console without changing the password Human: - # use the loginname root@my-org.my.domain - Username: 'root' - Password: 'RootPassword1!' + PasswordChangeRequired: false + Machine: + Machine: + Username: 'login-container' + Name: 'Login Container' + Pat.ExpirationDate: '2029-01-01T00:00:00Z' diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx index d5e3984568..88cd4c7700 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx @@ -1,5 +1,5 @@ --- -title: A ZITADEL Load Balancing Example +title: A Zitadel Load Balancing Example --- import CodeBlock from '@theme/CodeBlock'; @@ -8,16 +8,16 @@ import ExampleTraefikSource from '!!raw-loader!./example-traefik.yaml' import ExampleZITADELConfigSource from '!!raw-loader!./example-zitadel-config.yaml' import ExampleZITADELSecretsSource from '!!raw-loader!./example-zitadel-secrets.yaml' import ExampleZITADELInitStepsSource from '!!raw-loader!./example-zitadel-init-steps.yaml' -import NoteInstanceNotFound from '../troubleshooting/_note_instance_not_found.mdx'; -With this example configuration, you create a near production environment for ZITADEL with [Docker Compose](https://docs.docker.com/compose/). - -The stack consists of three long-running containers: -- A [Traefik](https://doc.traefik.io/traefik/) reverse proxy with upstream HTTP/2 enabled, issuing a self-signed TLS certificate. -- A secure ZITADEL container configured for a custom domain. As we terminate TLS with Traefik, we configure ZITADEL for `--tlsMode external`. +The stack consists of four long-running containers and a couple of short-lived containers: +- A [Traefik](https://doc.traefik.io/traefik/) reverse proxy container with upstream HTTP/2 enabled, issuing a self-signed TLS certificate. +- A Login container that is accessible via Traefik at `/ui/v2/login` +- A Zitadel container that is accessible via Traefik at all other paths than `/ui/v2/login`. - An insecure [PostgreSQL](https://www.postgresql.org/docs/current/index.html). -The setup is tested against Docker version 20.10.17 and Docker Compose version v2.2.3 +The Traefik container and the login container call the Zitadel container via the internal Docker network at `h2c://zitadel:8080` + +The setup is tested against Docker version 28.0.4 and Docker Compose version v2.34.0 By executing the commands below, you will download the following files: @@ -64,22 +64,11 @@ tr -dc A-Za-z0-9 ./zitadel-masterkey export ZITADEL_MASTERKEY="$(cat ./zitadel-masterkey)" # Run the database and application containers -docker compose up --detach +docker compose up --detach --wait ``` -Make `127.0.0.1` available at `my.domain`. For example, this can be achieved with an entry `127.0.0.1 my.domain` in the `/etc/hosts` file. - -Open your favorite internet browser at [https://my.domain/ui/console/](https://my.domain/ui/console/). -You can safely proceed, if your browser warns you about the insecure self-signed TLS certificate. -This is the IAM admin users login according to your configuration in the [example-zitadel-init-steps.yaml](./example-zitadel-init-steps.yaml): -- **username**: *root@my-org.my.domain* -- **password**: *RootPassword1!* +Open your favorite internet browser at https://127.0.0.1.sslip.io/ui/console?login_hint=zitadel-admin@zitadel.127.0.0.1.sslip.io. +Your browser warns you about the insecure self-signed TLS certificate. As 127.0.0.1.sslip.io resolves to your localhost, you can safely proceed. +Use the password *Password1!* to log in. Read more about [the login process](/guides/integrate/login/oidc/login-users). - - - -## Troubleshooting - -You can connect to the database like this: `docker exec -it loadbalancing-example-db-1 psql --host localhost` -For example, to show all login names: `docker exec -it loadbalancing-example-db-1 psql -d zitadel --host localhost -c 'select * from projections.login_names3'` From fa3efd9da3ad998242ad680e803f6c2bb256cb9f Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Tue, 29 Apr 2025 16:33:23 +0200 Subject: [PATCH 026/123] docs: fix Illegal byte sequence (#9750) # Which Problems Are Solved In some docs pages, we propose to generate a Zitadel masterkey using the command `tr -dc A-Za-z0-9 ./zitadel-masterkey +LC_ALL=C tr -dc '[:graph:]' ./zitadel-masterkey export ZITADEL_MASTERKEY="$(cat ./zitadel-masterkey)" # Run the database and application containers diff --git a/docs/docs/self-hosting/manage/configure/_compose.mdx b/docs/docs/self-hosting/manage/configure/_compose.mdx index 5e8b1c3937..837d4c6e62 100644 --- a/docs/docs/self-hosting/manage/configure/_compose.mdx +++ b/docs/docs/self-hosting/manage/configure/_compose.mdx @@ -43,7 +43,7 @@ wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosti # A single ZITADEL instance always needs the same 32 bytes long masterkey # Generate one to a file if you haven't done so already and pass it as environment variable -tr -dc A-Za-z0-9 ./zitadel-masterkey +LC_ALL=C tr -dc '[:graph:]' ./zitadel-masterkey export ZITADEL_MASTERKEY="$(cat ./zitadel-masterkey)" # Run the database and application containers diff --git a/docs/docs/self-hosting/manage/configure/_linuxunix.mdx b/docs/docs/self-hosting/manage/configure/_linuxunix.mdx index 6be833caea..65130ea195 100644 --- a/docs/docs/self-hosting/manage/configure/_linuxunix.mdx +++ b/docs/docs/self-hosting/manage/configure/_linuxunix.mdx @@ -35,7 +35,7 @@ wget https://raw.githubusercontent.com/zitadel/zitadel/main/docs/docs/self-hosti # A single ZITADEL instance always needs the same 32 characters long masterkey # If you haven't done so already, you can generate a new one # The key must be passed as argument -ZITADEL_MASTERKEY="$(tr -dc A-Za-z0-9 ./zitadel-masterkey +LC_ALL=C tr -dc '[:graph:]' ./zitadel-masterkey # Let the zitadel binary read configuration from environment variables zitadel start-from-init --masterkey "${ZITADEL_MASTERKEY}" --tlsMode disabled --masterkeyFile ./zitadel-masterkey From 91bc71db74fbfd6945822418a5fa8df4439f5757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 29 Apr 2025 16:54:53 +0200 Subject: [PATCH 027/123] fix(instance): add web key generation to instance defaults (#9815) # Which Problems Are Solved Webkeys were not generated with new instances when the webkey feature flag was enabled for instance defaults. This would cause a redirect loop with console for new instances on QA / coud. # How the Problems Are Solved - uncomment the webkeys section on defaults.yaml - Fix field naming of webkey config # Additional Changes - Add all available features as comments. - Make the improved performance type enum parsable from the config, untill now they were just ints. - Running of the enumer command created missing enum entries for feature keys. # Additional Context - Needs to be back-ported to v3 / next-rc Co-authored-by: Livio Spring --- cmd/defaults.yaml | 31 +++-- internal/api/grpc/feature/v2/converter.go | 6 +- internal/api/grpc/feature/v2beta/converter.go | 6 +- internal/feature/feature.go | 3 +- .../feature/improvedperformancetype_enumer.go | 106 ++++++++++++++++++ internal/feature/key_enumer.go | 66 +++++------ 6 files changed, 171 insertions(+), 47 deletions(-) create mode 100644 internal/feature/improvedperformancetype_enumer.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 55e14bbada..f20fbc03fc 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -959,16 +959,15 @@ DefaultInstance: EmailTemplate: CjwhZG9jdHlwZSBodG1sPgo8aHRtbCB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCIgeG1sbnM6dj0idXJuOnNjaGVtYXMtbWljcm9zb2Z0LWNvbTp2bWwiIHhtbG5zOm89InVybjpzY2hlbWFzLW1pY3Jvc29mdC1jb206b2ZmaWNlOm9mZmljZSI+CjxoZWFkPgogIDx0aXRsZT4KCiAgPC90aXRsZT4KICA8IS0tW2lmICFtc29dPjwhLS0+CiAgPG1ldGEgaHR0cC1lcXVpdj0iWC1VQS1Db21wYXRpYmxlIiBjb250ZW50PSJJRT1lZGdlIj4KICA8IS0tPCFbZW5kaWZdLS0+CiAgPG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9VVRGLTgiPgogIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MSI+CiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KICAgICNvdXRsb29rIGEgeyBwYWRkaW5nOjA7IH0KICAgIGJvZHkgeyBtYXJnaW46MDtwYWRkaW5nOjA7LXdlYmtpdC10ZXh0LXNpemUtYWRqdXN0OjEwMCU7LW1zLXRleHQtc2l6ZS1hZGp1c3Q6MTAwJTsgfQogICAgdGFibGUsIHRkIHsgYm9yZGVyLWNvbGxhcHNlOmNvbGxhcHNlO21zby10YWJsZS1sc3BhY2U6MHB0O21zby10YWJsZS1yc3BhY2U6MHB0OyB9CiAgICBpbWcgeyBib3JkZXI6MDtoZWlnaHQ6YXV0bztsaW5lLWhlaWdodDoxMDAlOyBvdXRsaW5lOm5vbmU7dGV4dC1kZWNvcmF0aW9uOm5vbmU7LW1zLWludGVycG9sYXRpb24tbW9kZTpiaWN1YmljOyB9CiAgICBwIHsgZGlzcGxheTpibG9jazttYXJnaW46MTNweCAwOyB9CiAgPC9zdHlsZT4KICA8IS0tW2lmIG1zb10+CiAgPHhtbD4KICAgIDxvOk9mZmljZURvY3VtZW50U2V0dGluZ3M+CiAgICAgIDxvOkFsbG93UE5HLz4KICAgICAgPG86UGl4ZWxzUGVySW5jaD45NjwvbzpQaXhlbHNQZXJJbmNoPgogICAgPC9vOk9mZmljZURvY3VtZW50U2V0dGluZ3M+CiAgPC94bWw+CiAgPCFbZW5kaWZdLS0+CiAgPCEtLVtpZiBsdGUgbXNvIDExXT4KICA8c3R5bGUgdHlwZT0idGV4dC9jc3MiPgogICAgLm1qLW91dGxvb2stZ3JvdXAtZml4IHsgd2lkdGg6MTAwJSAhaW1wb3J0YW50OyB9CiAgPC9zdHlsZT4KICA8IVtlbmRpZl0tLT4KCgogIDxzdHlsZSB0eXBlPSJ0ZXh0L2NzcyI+CiAgICBAbWVkaWEgb25seSBzY3JlZW4gYW5kIChtaW4td2lkdGg6NDgwcHgpIHsKICAgICAgLm1qLWNvbHVtbi1wZXItMTAwIHsgd2lkdGg6MTAwJSAhaW1wb3J0YW50OyBtYXgtd2lkdGg6IDEwMCU7IH0KICAgICAgLm1qLWNvbHVtbi1wZXItNjAgeyB3aWR0aDo2MCUgIWltcG9ydGFudDsgbWF4LXdpZHRoOiA2MCU7IH0KICAgIH0KICA8L3N0eWxlPgoKCiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4KCgoKICAgIEBtZWRpYSBvbmx5IHNjcmVlbiBhbmQgKG1heC13aWR0aDo0ODBweCkgewogICAgICB0YWJsZS5tai1mdWxsLXdpZHRoLW1vYmlsZSB7IHdpZHRoOiAxMDAlICFpbXBvcnRhbnQ7IH0KICAgICAgdGQubWotZnVsbC13aWR0aC1tb2JpbGUgeyB3aWR0aDogYXV0byAhaW1wb3J0YW50OyB9CiAgICB9CgogIDwvc3R5bGU+CiAgPHN0eWxlIHR5cGU9InRleHQvY3NzIj4uc2hhZG93IGEgewogICAgYm94LXNoYWRvdzogMHB4IDNweCAxcHggLTJweCByZ2JhKDAsIDAsIDAsIDAuMiksIDBweCAycHggMnB4IDBweCByZ2JhKDAsIDAsIDAsIDAuMTQpLCAwcHggMXB4IDVweCAwcHggcmdiYSgwLCAwLCAwLCAwLjEyKTsKICB9PC9zdHlsZT4KCiAge3tpZiAuRm9udFVSTH19CiAgPHN0eWxlPgogICAgQGZvbnQtZmFjZSB7CiAgICAgIGZvbnQtZmFtaWx5OiAne3suRm9udEZhY2VGYW1pbHl9fSc7CiAgICAgIGZvbnQtc3R5bGU6IG5vcm1hbDsKICAgICAgZm9udC1kaXNwbGF5OiBzd2FwOwogICAgICBzcmM6IHVybCh7ey5Gb250VVJMfX0pOwogICAgfQogIDwvc3R5bGU+CiAge3tlbmR9fQoKPC9oZWFkPgo8Ym9keSBzdHlsZT0id29yZC1zcGFjaW5nOm5vcm1hbDsiPgoKCjxkaXYKICAgICAgICBzdHlsZT0iIgo+CgogIDx0YWJsZQogICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9ImJhY2tncm91bmQ6e3suQmFja2dyb3VuZENvbG9yfX07YmFja2dyb3VuZC1jb2xvcjp7ey5CYWNrZ3JvdW5kQ29sb3J9fTt3aWR0aDoxMDAlO2JvcmRlci1yYWRpdXM6MTZweDsiCiAgPgogICAgPHRib2R5PgogICAgPHRyPgogICAgICA8dGQ+CgoKICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIGNsYXNzPSIiIHN0eWxlPSJ3aWR0aDo4MDBweDsiIHdpZHRoPSI4MDAiID48dHI+PHRkIHN0eWxlPSJsaW5lLWhlaWdodDowcHg7Zm9udC1zaXplOjBweDttc28tbGluZS1oZWlnaHQtcnVsZTpleGFjdGx5OyI+PCFbZW5kaWZdLS0+CgoKICAgICAgICA8ZGl2ICBzdHlsZT0ibWFyZ2luOjBweCBhdXRvO2JvcmRlci1yYWRpdXM6MTZweDttYXgtd2lkdGg6ODAwcHg7Ij4KCiAgICAgICAgICA8dGFibGUKICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9IndpZHRoOjEwMCU7Ym9yZGVyLXJhZGl1czoxNnB4OyIKICAgICAgICAgID4KICAgICAgICAgICAgPHRib2R5PgogICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICBzdHlsZT0iZGlyZWN0aW9uOmx0cjtmb250LXNpemU6MHB4O3BhZGRpbmc6MjBweCAwO3BhZGRpbmctbGVmdDowO3RleHQtYWxpZ246Y2VudGVyOyIKICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgcm9sZT0icHJlc2VudGF0aW9uIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCI+PHRyPjx0ZCBjbGFzcz0iIiB3aWR0aD0iODAwcHgiID48IVtlbmRpZl0tLT4KCiAgICAgICAgICAgICAgICA8dGFibGUKICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9IndpZHRoOjEwMCU7IgogICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICA8dGQ+CgoKICAgICAgICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjx0YWJsZSBhbGlnbj0iY2VudGVyIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgY2xhc3M9IiIgc3R5bGU9IndpZHRoOjgwMHB4OyIgd2lkdGg9IjgwMCIgPjx0cj48dGQgc3R5bGU9ImxpbmUtaGVpZ2h0OjBweDtmb250LXNpemU6MHB4O21zby1saW5lLWhlaWdodC1ydWxlOmV4YWN0bHk7Ij48IVtlbmRpZl0tLT4KCgogICAgICAgICAgICAgICAgICAgICAgPGRpdiAgc3R5bGU9Im1hcmdpbjowcHggYXV0bzttYXgtd2lkdGg6ODAwcHg7Ij4KCiAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIGJvcmRlcj0iMCIgY2VsbHBhZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiByb2xlPSJwcmVzZW50YXRpb24iIHN0eWxlPSJ3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgIDx0Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImRpcmVjdGlvbjpsdHI7Zm9udC1zaXplOjBweDtwYWRkaW5nOjA7dGV4dC1hbGlnbjpjZW50ZXI7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgcm9sZT0icHJlc2VudGF0aW9uIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCI+PHRyPjx0ZCBjbGFzcz0iIiBzdHlsZT0id2lkdGg6ODAwcHg7IiA+PCFbZW5kaWZdLS0+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8ZGl2CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgY2xhc3M9Im1qLWNvbHVtbi1wZXItMTAwIG1qLW91dGxvb2stZ3JvdXAtZml4IiBzdHlsZT0iZm9udC1zaXplOjA7bGluZS1oZWlnaHQ6MDt0ZXh0LWFsaWduOmxlZnQ7ZGlzcGxheTppbmxpbmUtYmxvY2s7d2lkdGg6MTAwJTtkaXJlY3Rpb246bHRyOyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjx0YWJsZSBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiA+PHRyPjx0ZCBzdHlsZT0idmVydGljYWwtYWxpZ246dG9wO3dpZHRoOjgwMHB4OyIgPjwhW2VuZGlmXS0tPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8ZGl2CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBjbGFzcz0ibWotY29sdW1uLXBlci0xMDAgbWotb3V0bG9vay1ncm91cC1maXgiIHN0eWxlPSJmb250LXNpemU6MHB4O3RleHQtYWxpZ246bGVmdDtkaXJlY3Rpb246bHRyO2Rpc3BsYXk6aW5saW5lLWJsb2NrO3ZlcnRpY2FsLWFsaWduOnRvcDt3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGJvcmRlcj0iMCIgY2VsbHBhZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiByb2xlPSJwcmVzZW50YXRpb24iIHdpZHRoPSIxMDAlIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQgIHN0eWxlPSJ2ZXJ0aWNhbC1hbGlnbjp0b3A7cGFkZGluZzowOyI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB7e2lmIC5Mb2dvVVJMfX0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iIiB3aWR0aD0iMTAwJSIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRib2R5PgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgc3R5bGU9ImZvbnQtc2l6ZTowcHg7cGFkZGluZzo1MHB4IDAgMzBweCAwO3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iYm9yZGVyLWNvbGxhcHNlOmNvbGxhcHNlO2JvcmRlci1zcGFjaW5nOjBweDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZCAgc3R5bGU9IndpZHRoOjE4MHB4OyI+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGltZwogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBoZWlnaHQ9ImF1dG8iIHNyYz0ie3suTG9nb1VSTH19IiBzdHlsZT0iYm9yZGVyOjA7Ym9yZGVyLXJhZGl1czo4cHg7ZGlzcGxheTpibG9jaztvdXRsaW5lOm5vbmU7dGV4dC1kZWNvcmF0aW9uOm5vbmU7aGVpZ2h0OmF1dG87d2lkdGg6MTAwJTtmb250LXNpemU6MTNweDsiIHdpZHRoPSIxODAiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAvPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3Rib2R5PgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90ZD4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAge3tlbmR9fQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L2Rpdj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPCEtLVtpZiBtc28gfCBJRV0+PC90ZD48L3RyPjwvdGFibGU+PCFbZW5kaWZdLS0+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvZGl2PgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPCEtLVtpZiBtc28gfCBJRV0+PC90ZD48L3RyPjwvdGFibGU+PCFbZW5kaWZdLS0+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgPC90Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICAgICAgICA8L2Rpdj4KCgogICAgICAgICAgICAgICAgICAgICAgPCEtLVtpZiBtc28gfCBJRV0+PC90ZD48L3RyPjwvdGFibGU+PCFbZW5kaWZdLS0+CgoKICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICA8L3RyPgogICAgICAgICAgICAgICAgICA8L3Rib2R5PgogICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48L3RkPjwvdHI+PHRyPjx0ZCBjbGFzcz0iIiB3aWR0aD0iODAwcHgiID48IVtlbmRpZl0tLT4KCiAgICAgICAgICAgICAgICA8dGFibGUKICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9IndpZHRoOjEwMCU7IgogICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICA8dGQ+CgoKICAgICAgICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjx0YWJsZSBhbGlnbj0iY2VudGVyIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgY2xhc3M9IiIgc3R5bGU9IndpZHRoOjgwMHB4OyIgd2lkdGg9IjgwMCIgPjx0cj48dGQgc3R5bGU9ImxpbmUtaGVpZ2h0OjBweDtmb250LXNpemU6MHB4O21zby1saW5lLWhlaWdodC1ydWxlOmV4YWN0bHk7Ij48IVtlbmRpZl0tLT4KCgogICAgICAgICAgICAgICAgICAgICAgPGRpdiAgc3R5bGU9Im1hcmdpbjowcHggYXV0bzttYXgtd2lkdGg6ODAwcHg7Ij4KCiAgICAgICAgICAgICAgICAgICAgICAgIDx0YWJsZQogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIGJvcmRlcj0iMCIgY2VsbHBhZGRpbmc9IjAiIGNlbGxzcGFjaW5nPSIwIiByb2xlPSJwcmVzZW50YXRpb24iIHN0eWxlPSJ3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgIDx0Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImRpcmVjdGlvbjpsdHI7Zm9udC1zaXplOjBweDtwYWRkaW5nOjA7dGV4dC1hbGlnbjpjZW50ZXI7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgcm9sZT0icHJlc2VudGF0aW9uIiBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCI+PHRyPjx0ZCBjbGFzcz0iIiBzdHlsZT0idmVydGljYWwtYWxpZ246dG9wO3dpZHRoOjQ4MHB4OyIgPjwhW2VuZGlmXS0tPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGRpdgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGNsYXNzPSJtai1jb2x1bW4tcGVyLTYwIG1qLW91dGxvb2stZ3JvdXAtZml4IiBzdHlsZT0iZm9udC1zaXplOjBweDt0ZXh0LWFsaWduOmxlZnQ7ZGlyZWN0aW9uOmx0cjtkaXNwbGF5OmlubGluZS1ibG9jazt2ZXJ0aWNhbC1hbGlnbjp0b3A7d2lkdGg6MTAwJTsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiB3aWR0aD0iMTAwJSIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZCAgc3R5bGU9InZlcnRpY2FsLWFsaWduOnRvcDtwYWRkaW5nOjA7Ij4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iIiB3aWR0aD0iMTAwJSIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGJvZHk+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dGQKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBhbGlnbj0iY2VudGVyIiBzdHlsZT0iZm9udC1zaXplOjBweDtwYWRkaW5nOjEwcHggMjVweDt3b3JkLWJyZWFrOmJyZWFrLXdvcmQ7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDxkaXYKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHN0eWxlPSJmb250LWZhbWlseTp7ey5Gb250RmFtaWx5fX07Zm9udC1zaXplOjI0cHg7Zm9udC13ZWlnaHQ6NTAwO2xpbmUtaGVpZ2h0OjE7dGV4dC1hbGlnbjpjZW50ZXI7Y29sb3I6e3suRm9udENvbG9yfX07IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID57ey5HcmVldGluZ319PC9kaXY+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIHN0eWxlPSJmb250LXNpemU6MHB4O3BhZGRpbmc6MTBweCAyNXB4O3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGRpdgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImZvbnQtZmFtaWx5Ont7LkZvbnRGYW1pbHl9fTtmb250LXNpemU6MTZweDtmb250LXdlaWdodDpsaWdodDtsaW5lLWhlaWdodDoxLjU7dGV4dC1hbGlnbjpjZW50ZXI7Y29sb3I6e3suRm9udENvbG9yfX07IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID57ey5UZXh0fX08L2Rpdj4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgoKCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8dHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0ZAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGFsaWduPSJjZW50ZXIiIHZlcnRpY2FsLWFsaWduPSJtaWRkbGUiIGNsYXNzPSJzaGFkb3ciIHN0eWxlPSJmb250LXNpemU6MHB4O3BhZGRpbmc6MTBweCAyNXB4O3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRhYmxlCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBib3JkZXI9IjAiIGNlbGxwYWRkaW5nPSIwIiBjZWxsc3BhY2luZz0iMCIgcm9sZT0icHJlc2VudGF0aW9uIiBzdHlsZT0iYm9yZGVyLWNvbGxhcHNlOnNlcGFyYXRlO2xpbmUtaGVpZ2h0OjEwMCU7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgYmdjb2xvcj0ie3suUHJpbWFyeUNvbG9yfX0iIHJvbGU9InByZXNlbnRhdGlvbiIgc3R5bGU9ImJvcmRlcjpub25lO2JvcmRlci1yYWRpdXM6NnB4O2N1cnNvcjphdXRvO21zby1wYWRkaW5nLWFsdDoxMHB4IDI1cHg7YmFja2dyb3VuZDp7ey5QcmltYXJ5Q29sb3J9fTsiIHZhbGlnbj0ibWlkZGxlIgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGEKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIGhyZWY9Int7LlVSTH19IiByZWw9Im5vb3BlbmVyIG5vcmVmZXJyZXIgbm90cmFjayIgc3R5bGU9ImRpc3BsYXk6aW5saW5lLWJsb2NrO2JhY2tncm91bmQ6e3suUHJpbWFyeUNvbG9yfX07Y29sb3I6I2ZmZmZmZjtmb250LWZhbWlseTp7ey5Gb250RmFtaWx5fX07Zm9udC1zaXplOjE0cHg7Zm9udC13ZWlnaHQ6NTAwO2xpbmUtaGVpZ2h0OjEyMCU7bWFyZ2luOjA7dGV4dC1kZWNvcmF0aW9uOm5vbmU7dGV4dC10cmFuc2Zvcm06bm9uZTtwYWRkaW5nOjEwcHggMjVweDttc28tcGFkZGluZy1hbHQ6MHB4O2JvcmRlci1yYWRpdXM6NnB4OyIgdGFyZ2V0PSJfYmxhbmsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAge3suQnV0dG9uVGV4dH19CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC9hPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90ZD4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICB7e2lmIC5JbmNsdWRlRm9vdGVyfX0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgc3R5bGU9ImZvbnQtc2l6ZTowcHg7cGFkZGluZzoxMHB4IDI1cHg7cGFkZGluZy10b3A6MjBweDtwYWRkaW5nLXJpZ2h0OjIwcHg7cGFkZGluZy1ib3R0b206MjBweDtwYWRkaW5nLWxlZnQ6MjBweDt3b3JkLWJyZWFrOmJyZWFrLXdvcmQ7IgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDxwCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICBzdHlsZT0iYm9yZGVyLXRvcDpzb2xpZCAycHggI2RiZGJkYjtmb250LXNpemU6MXB4O21hcmdpbjowcHggYXV0bzt3aWR0aDoxMDAlOyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC9wPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48dGFibGUgYWxpZ249ImNlbnRlciIgYm9yZGVyPSIwIiBjZWxscGFkZGluZz0iMCIgY2VsbHNwYWNpbmc9IjAiIHN0eWxlPSJib3JkZXItdG9wOnNvbGlkIDJweCAjZGJkYmRiO2ZvbnQtc2l6ZToxcHg7bWFyZ2luOjBweCBhdXRvO3dpZHRoOjQ0MHB4OyIgcm9sZT0icHJlc2VudGF0aW9uIiB3aWR0aD0iNDQwcHgiID48dHI+PHRkIHN0eWxlPSJoZWlnaHQ6MDtsaW5lLWhlaWdodDowOyI+ICZuYnNwOwogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+PC90cj48L3RhYmxlPjwhW2VuZGlmXS0tPgoKCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDx0cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPHRkCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgYWxpZ249ImNlbnRlciIgc3R5bGU9ImZvbnQtc2l6ZTowcHg7cGFkZGluZzoxNnB4O3dvcmQtYnJlYWs6YnJlYWstd29yZDsiCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgID4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPGRpdgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgc3R5bGU9ImZvbnQtZmFtaWx5Ont7LkZvbnRGYW1pbHl9fTtmb250LXNpemU6MTNweDtsaW5lLWhlaWdodDoxO3RleHQtYWxpZ246Y2VudGVyO2NvbG9yOnt7LkZvbnRDb2xvcn19OyIKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA+e3suRm9vdGVyVGV4dH19PC9kaXY+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RkPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIHt7ZW5kfX0KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90YWJsZT4KCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RyPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC90Ym9keT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgPC9kaXY+CgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48L3RkPjwvdHI+PC90YWJsZT48IVtlbmRpZl0tLT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgICAgICAgICAgPC90cj4KICAgICAgICAgICAgICAgICAgICAgICAgICA8L3Rib2R5PgogICAgICAgICAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgICAgICAgIDwvZGl2PgoKCiAgICAgICAgICAgICAgICAgICAgICA8IS0tW2lmIG1zbyB8IElFXT48L3RkPjwvdHI+PC90YWJsZT48IVtlbmRpZl0tLT4KCgogICAgICAgICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICAgICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjwvdGQ+PC90cj48L3RhYmxlPjwhW2VuZGlmXS0tPgogICAgICAgICAgICAgIDwvdGQ+CiAgICAgICAgICAgIDwvdHI+CiAgICAgICAgICAgIDwvdGJvZHk+CiAgICAgICAgICA8L3RhYmxlPgoKICAgICAgICA8L2Rpdj4KCgogICAgICAgIDwhLS1baWYgbXNvIHwgSUVdPjwvdGQ+PC90cj48L3RhYmxlPjwhW2VuZGlmXS0tPgoKCiAgICAgIDwvdGQ+CiAgICA8L3RyPgogICAgPC90Ym9keT4KICA8L3RhYmxlPgoKPC9kaXY+Cgo8L2JvZHk+CjwvaHRtbD4K # ZITADEL_DEFAULTINSTANCE_EMAILTEMPLATE # WebKeys configures the OIDC token signing keys that are generated when a new instance is created. - # WebKeys are still in alpha, so the config is disabled here. This will prevent generation of keys for now. - # WebKeys: - # Type: "rsa" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_TYPE - # Config: - # Bits: "2048" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_BITS - # Hasher: "sha256" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_HASHER + WebKeys: + Type: "rsa" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_TYPE + Config: + RSABits: "2048" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_BITS + RSAHasher: "sha256" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_HASHER # WebKeys: # Type: "ecdsa" # Config: - # Curve: "P256" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_CURVE + # EllipticCurve: "P256" # ZITADEL_DEFAULTINSTANCE_WEBKEYS_CONFIG_CURVE # Sets the default values for lifetime and expiration for OIDC in each newly created instance # This default can be overwritten for each instance during runtime @@ -1101,7 +1100,25 @@ DefaultInstance: LoginDefaultOrg: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINDEFAULTORG # TriggerIntrospectionProjections: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TRIGGERINTROSPECTIONPROJECTIONS # LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION + # UserSchema: false # ZITADEL_DEFAULTINSTANCE_FEATURES_USERSCHEMA + # TokenExchange: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TOKENEXCHANGE + # ImprovedPerformance: # ZITADEL_DEFAULTINSTANCE_FEATURES_IMPROVEDPERFORMANCE + # - OrgByID + # - ProjectGrant + # - Project + # - UserGrant + # - OrgDomainVerified + # WebKey: false # ZITADEL_DEFAULTINSTANCE_FEATURES_WEBKEY + # DebugOIDCParentError: false # ZITADEL_DEFAULTINSTANCE_FEATURES_DEBUGOIDCPARENTERROR + # OIDCSingleV1SessionTermination: false # ZITADEL_DEFAULTINSTANCE_FEATURES_OIDCSINGLEV1SESSIONTERMINATION + # DisableUserTokenEvent: false # ZITADEL_DEFAULTINSTANCE_FEATURES_DISABLEUSERTOKENEVENT + # EnableBackChannelLogout: false # ZITADEL_DEFAULTINSTANCE_FEATURES_ENABLEBACKCHANNELLOGOUT + # LoginV2: + # Required: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED + # BaseURI: "" # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI # PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2 + # ConsoleUseV2UserApi: false # ZITADEL_DEFAULTINSTANCE_FEATURES_CONSOLEUSEV2USERAPI + Limits: # AuditLogRetention limits the number of events that can be queried via the events API by their age. # A value of "0s" means that all events are available. diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index baa45c6c6e..e146ac2db6 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -172,7 +172,7 @@ func improvedPerformanceTypesToPb(types []feature.ImprovedPerformanceType) []fea func improvedPerformanceTypeToPb(typ feature.ImprovedPerformanceType) feature_pb.ImprovedPerformance { switch typ { - case feature.ImprovedPerformanceTypeUnknown: + case feature.ImprovedPerformanceTypeUnspecified: return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED case feature.ImprovedPerformanceTypeOrgByID: return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID @@ -205,7 +205,7 @@ func improvedPerformanceListToDomain(list []feature_pb.ImprovedPerformance) []fe func improvedPerformanceToDomain(typ feature_pb.ImprovedPerformance) feature.ImprovedPerformanceType { switch typ { case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED: - return feature.ImprovedPerformanceTypeUnknown + return feature.ImprovedPerformanceTypeUnspecified case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID: return feature.ImprovedPerformanceTypeOrgByID case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT_GRANT: @@ -217,6 +217,6 @@ func improvedPerformanceToDomain(typ feature_pb.ImprovedPerformance) feature.Imp case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_DOMAIN_VERIFIED: return feature.ImprovedPerformanceTypeOrgDomainVerified default: - return feature.ImprovedPerformanceTypeUnknown + return feature.ImprovedPerformanceTypeUnspecified } } diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index bbb375716e..9739e1c4c8 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -109,7 +109,7 @@ func improvedPerformanceTypesToPb(types []feature.ImprovedPerformanceType) []fea func improvedPerformanceTypeToPb(typ feature.ImprovedPerformanceType) feature_pb.ImprovedPerformance { switch typ { - case feature.ImprovedPerformanceTypeUnknown: + case feature.ImprovedPerformanceTypeUnspecified: return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED case feature.ImprovedPerformanceTypeOrgByID: return feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID @@ -142,7 +142,7 @@ func improvedPerformanceListToDomain(list []feature_pb.ImprovedPerformance) []fe func improvedPerformanceToDomain(typ feature_pb.ImprovedPerformance) feature.ImprovedPerformanceType { switch typ { case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_UNSPECIFIED: - return feature.ImprovedPerformanceTypeUnknown + return feature.ImprovedPerformanceTypeUnspecified case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID: return feature.ImprovedPerformanceTypeOrgByID case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_PROJECT_GRANT: @@ -154,6 +154,6 @@ func improvedPerformanceToDomain(typ feature_pb.ImprovedPerformance) feature.Imp case feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_DOMAIN_VERIFIED: return feature.ImprovedPerformanceTypeOrgDomainVerified default: - return feature.ImprovedPerformanceTypeUnknown + return feature.ImprovedPerformanceTypeUnspecified } } diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 389b750483..f500b80eb3 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -57,10 +57,11 @@ type Features struct { ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` } +//go:generate enumer -type ImprovedPerformanceType -trimprefix ImprovedPerformanceType -text type ImprovedPerformanceType int32 const ( - ImprovedPerformanceTypeUnknown = iota + ImprovedPerformanceTypeUnspecified ImprovedPerformanceType = iota ImprovedPerformanceTypeOrgByID ImprovedPerformanceTypeProjectGrant ImprovedPerformanceTypeProject diff --git a/internal/feature/improvedperformancetype_enumer.go b/internal/feature/improvedperformancetype_enumer.go new file mode 100644 index 0000000000..a12673c205 --- /dev/null +++ b/internal/feature/improvedperformancetype_enumer.go @@ -0,0 +1,106 @@ +// Code generated by "enumer -type ImprovedPerformanceType -trimprefix ImprovedPerformanceType -text"; DO NOT EDIT. + +package feature + +import ( + "fmt" + "strings" +) + +const _ImprovedPerformanceTypeName = "UnspecifiedOrgByIDProjectGrantProjectUserGrantOrgDomainVerified" + +var _ImprovedPerformanceTypeIndex = [...]uint8{0, 11, 18, 30, 37, 46, 63} + +const _ImprovedPerformanceTypeLowerName = "unspecifiedorgbyidprojectgrantprojectusergrantorgdomainverified" + +func (i ImprovedPerformanceType) String() string { + if i < 0 || i >= ImprovedPerformanceType(len(_ImprovedPerformanceTypeIndex)-1) { + return fmt.Sprintf("ImprovedPerformanceType(%d)", i) + } + return _ImprovedPerformanceTypeName[_ImprovedPerformanceTypeIndex[i]:_ImprovedPerformanceTypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _ImprovedPerformanceTypeNoOp() { + var x [1]struct{} + _ = x[ImprovedPerformanceTypeUnspecified-(0)] + _ = x[ImprovedPerformanceTypeOrgByID-(1)] + _ = x[ImprovedPerformanceTypeProjectGrant-(2)] + _ = x[ImprovedPerformanceTypeProject-(3)] + _ = x[ImprovedPerformanceTypeUserGrant-(4)] + _ = x[ImprovedPerformanceTypeOrgDomainVerified-(5)] +} + +var _ImprovedPerformanceTypeValues = []ImprovedPerformanceType{ImprovedPerformanceTypeUnspecified, ImprovedPerformanceTypeOrgByID, ImprovedPerformanceTypeProjectGrant, ImprovedPerformanceTypeProject, ImprovedPerformanceTypeUserGrant, ImprovedPerformanceTypeOrgDomainVerified} + +var _ImprovedPerformanceTypeNameToValueMap = map[string]ImprovedPerformanceType{ + _ImprovedPerformanceTypeName[0:11]: ImprovedPerformanceTypeUnspecified, + _ImprovedPerformanceTypeLowerName[0:11]: ImprovedPerformanceTypeUnspecified, + _ImprovedPerformanceTypeName[11:18]: ImprovedPerformanceTypeOrgByID, + _ImprovedPerformanceTypeLowerName[11:18]: ImprovedPerformanceTypeOrgByID, + _ImprovedPerformanceTypeName[18:30]: ImprovedPerformanceTypeProjectGrant, + _ImprovedPerformanceTypeLowerName[18:30]: ImprovedPerformanceTypeProjectGrant, + _ImprovedPerformanceTypeName[30:37]: ImprovedPerformanceTypeProject, + _ImprovedPerformanceTypeLowerName[30:37]: ImprovedPerformanceTypeProject, + _ImprovedPerformanceTypeName[37:46]: ImprovedPerformanceTypeUserGrant, + _ImprovedPerformanceTypeLowerName[37:46]: ImprovedPerformanceTypeUserGrant, + _ImprovedPerformanceTypeName[46:63]: ImprovedPerformanceTypeOrgDomainVerified, + _ImprovedPerformanceTypeLowerName[46:63]: ImprovedPerformanceTypeOrgDomainVerified, +} + +var _ImprovedPerformanceTypeNames = []string{ + _ImprovedPerformanceTypeName[0:11], + _ImprovedPerformanceTypeName[11:18], + _ImprovedPerformanceTypeName[18:30], + _ImprovedPerformanceTypeName[30:37], + _ImprovedPerformanceTypeName[37:46], + _ImprovedPerformanceTypeName[46:63], +} + +// ImprovedPerformanceTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func ImprovedPerformanceTypeString(s string) (ImprovedPerformanceType, error) { + if val, ok := _ImprovedPerformanceTypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _ImprovedPerformanceTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to ImprovedPerformanceType values", s) +} + +// ImprovedPerformanceTypeValues returns all values of the enum +func ImprovedPerformanceTypeValues() []ImprovedPerformanceType { + return _ImprovedPerformanceTypeValues +} + +// ImprovedPerformanceTypeStrings returns a slice of all String values of the enum +func ImprovedPerformanceTypeStrings() []string { + strs := make([]string, len(_ImprovedPerformanceTypeNames)) + copy(strs, _ImprovedPerformanceTypeNames) + return strs +} + +// IsAImprovedPerformanceType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i ImprovedPerformanceType) IsAImprovedPerformanceType() bool { + for _, v := range _ImprovedPerformanceTypeValues { + if i == v { + return true + } + } + return false +} + +// MarshalText implements the encoding.TextMarshaler interface for ImprovedPerformanceType +func (i ImprovedPerformanceType) MarshalText() ([]byte, error) { + return []byte(i.String()), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface for ImprovedPerformanceType +func (i *ImprovedPerformanceType) UnmarshalText(text []byte) error { + var err error + *i, err = ImprovedPerformanceTypeString(string(text)) + return err +} diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index 6466061718..a47b3eb4d9 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" -var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 113, 133, 140, 163, 197, 221, 247, 255, 274, 297} +var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 124, 144, 151, 174, 208, 232, 258, 266, 285, 308} -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactionsimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" func (i Key) String() string { if i < 0 || i >= Key(len(_KeyIndex)-1) { @@ -57,26 +57,26 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName[81:92]: KeyUserSchema, _KeyName[92:106]: KeyTokenExchange, _KeyLowerName[92:106]: KeyTokenExchange, - _KeyName[106:113]: KeyActionsDeprecated, - _KeyLowerName[106:113]: KeyActionsDeprecated, - _KeyName[113:133]: KeyImprovedPerformance, - _KeyLowerName[113:133]: KeyImprovedPerformance, - _KeyName[133:140]: KeyWebKey, - _KeyLowerName[133:140]: KeyWebKey, - _KeyName[140:163]: KeyDebugOIDCParentError, - _KeyLowerName[140:163]: KeyDebugOIDCParentError, - _KeyName[163:197]: KeyOIDCSingleV1SessionTermination, - _KeyLowerName[163:197]: KeyOIDCSingleV1SessionTermination, - _KeyName[197:221]: KeyDisableUserTokenEvent, - _KeyLowerName[197:221]: KeyDisableUserTokenEvent, - _KeyName[221:247]: KeyEnableBackChannelLogout, - _KeyLowerName[221:247]: KeyEnableBackChannelLogout, - _KeyName[247:255]: KeyLoginV2, - _KeyLowerName[247:255]: KeyLoginV2, - _KeyName[255:274]: KeyPermissionCheckV2, - _KeyLowerName[255:274]: KeyPermissionCheckV2, - _KeyName[274:297]: KeyConsoleUseV2UserApi, - _KeyLowerName[274:297]: KeyConsoleUseV2UserApi, + _KeyName[106:124]: KeyActionsDeprecated, + _KeyLowerName[106:124]: KeyActionsDeprecated, + _KeyName[124:144]: KeyImprovedPerformance, + _KeyLowerName[124:144]: KeyImprovedPerformance, + _KeyName[144:151]: KeyWebKey, + _KeyLowerName[144:151]: KeyWebKey, + _KeyName[151:174]: KeyDebugOIDCParentError, + _KeyLowerName[151:174]: KeyDebugOIDCParentError, + _KeyName[174:208]: KeyOIDCSingleV1SessionTermination, + _KeyLowerName[174:208]: KeyOIDCSingleV1SessionTermination, + _KeyName[208:232]: KeyDisableUserTokenEvent, + _KeyLowerName[208:232]: KeyDisableUserTokenEvent, + _KeyName[232:258]: KeyEnableBackChannelLogout, + _KeyLowerName[232:258]: KeyEnableBackChannelLogout, + _KeyName[258:266]: KeyLoginV2, + _KeyLowerName[258:266]: KeyLoginV2, + _KeyName[266:285]: KeyPermissionCheckV2, + _KeyLowerName[266:285]: KeyPermissionCheckV2, + _KeyName[285:308]: KeyConsoleUseV2UserApi, + _KeyLowerName[285:308]: KeyConsoleUseV2UserApi, } var _KeyNames = []string{ @@ -86,16 +86,16 @@ var _KeyNames = []string{ _KeyName[61:81], _KeyName[81:92], _KeyName[92:106], - _KeyName[106:113], - _KeyName[113:133], - _KeyName[133:140], - _KeyName[140:163], - _KeyName[163:197], - _KeyName[197:221], - _KeyName[221:247], - _KeyName[247:255], - _KeyName[255:274], - _KeyName[274:297], + _KeyName[106:124], + _KeyName[124:144], + _KeyName[144:151], + _KeyName[151:174], + _KeyName[174:208], + _KeyName[208:232], + _KeyName[232:258], + _KeyName[258:266], + _KeyName[266:285], + _KeyName[285:308], } // KeyString retrieves an enum value from the enum constants string name. From 181186e477f7ae1779cf2b4429f25e00b9712e98 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Tue, 29 Apr 2025 17:29:16 +0200 Subject: [PATCH 028/123] fix(mirror): add max auth request age configuration (#9812) # Which Problems Are Solved The `auth.auth_requests` table is not cleaned up so long running Zitadel installations can contain many rows. The mirror command can take long because a the data are first copied into memory (or disk) on cockroach and users do not get any output from mirror. This is unfortunate because people don't know if Zitadel got stuck. # How the Problems Are Solved Enhance logging throughout the projection processes and introduce a configuration option for the maximum age of authentication requests. # Additional Changes None # Additional Context closes https://github.com/zitadel/zitadel/issues/9764 --------- Co-authored-by: Livio Spring --- cmd/mirror/auth.go | 15 ++- cmd/mirror/config.go | 3 +- cmd/mirror/defaults.yaml | 97 ++++++++++--------- cmd/mirror/event_store.go | 5 + cmd/mirror/projections.go | 10 +- cmd/mirror/system.go | 14 +-- docs/docs/self-hosting/manage/cli/mirror.mdx | 3 + .../eventsourcing/handler/handler.go | 8 +- .../eventsourcing/handler/handler.go | 8 +- internal/notification/projections.go | 8 +- internal/query/projection/projection.go | 12 ++- 11 files changed, 116 insertions(+), 67 deletions(-) diff --git a/cmd/mirror/auth.go b/cmd/mirror/auth.go index 0eba10d05f..3d7ae45bce 100644 --- a/cmd/mirror/auth.go +++ b/cmd/mirror/auth.go @@ -4,6 +4,7 @@ import ( "context" _ "embed" "io" + "strconv" "time" "github.com/jackc/pgx/v5/stdlib" @@ -41,12 +42,16 @@ func copyAuth(ctx context.Context, config *Migration) { logging.OnError(err).Fatal("unable to connect to destination database") defer destClient.Close() - copyAuthRequests(ctx, sourceClient, destClient) + copyAuthRequests(ctx, sourceClient, destClient, config.MaxAuthRequestAge) } -func copyAuthRequests(ctx context.Context, source, dest *database.DB) { +func copyAuthRequests(ctx context.Context, source, dest *database.DB, maxAuthRequestAge time.Duration) { start := time.Now() + logging.Info("creating index on auth.auth_requests.change_date to speed up copy in source database") + _, err := source.ExecContext(ctx, "CREATE INDEX CONCURRENTLY IF NOT EXISTS auth_requests_change_date ON auth.auth_requests (change_date)") + logging.OnError(err).Fatal("unable to create index on auth.auth_requests.change_date") + sourceConn, err := source.Conn(ctx) logging.OnError(err).Fatal("unable to acquire connection") defer sourceConn.Close() @@ -55,9 +60,9 @@ func copyAuthRequests(ctx context.Context, source, dest *database.DB) { errs := make(chan error, 1) go func() { - err = sourceConn.Raw(func(driverConn interface{}) error { + err = sourceConn.Raw(func(driverConn any) 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") + _, 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()+" AND change_date > NOW() - INTERVAL '"+strconv.FormatFloat(maxAuthRequestAge.Seconds(), 'f', -1, 64)+" seconds') TO STDOUT") w.Close() return err }) @@ -69,7 +74,7 @@ func copyAuthRequests(ctx context.Context, source, dest *database.DB) { defer destConn.Close() var affected int64 - err = destConn.Raw(func(driverConn interface{}) error { + err = destConn.Raw(func(driverConn any) error { conn := driverConn.(*stdlib.Conn).Conn() if shouldReplace { diff --git a/cmd/mirror/config.go b/cmd/mirror/config.go index 9d0113a1d7..5bb19f12de 100644 --- a/cmd/mirror/config.go +++ b/cmd/mirror/config.go @@ -23,7 +23,8 @@ type Migration struct { Source database.Config Destination database.Config - EventBulkSize uint32 + EventBulkSize uint32 + MaxAuthRequestAge time.Duration Log *logging.Config Machine *id.Config diff --git a/cmd/mirror/defaults.yaml b/cmd/mirror/defaults.yaml index 4b42c06534..4d8a0a4eae 100644 --- a/cmd/mirror/defaults.yaml +++ b/cmd/mirror/defaults.yaml @@ -1,61 +1,64 @@ 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 - MaxConnLifetime: 30m # ZITADEL_DATABASE_COCKROACH_MAXCONNLIFETIME - MaxConnIdleTime: 5m # ZITADEL_DATABASE_COCKROACH_MAXCONNIDLETIME - Options: "" # ZITADEL_DATABASE_COCKROACH_OPTIONS + Host: localhost # ZITADEL_SOURCE_COCKROACH_HOST + Port: 26257 # ZITADEL_SOURCE_COCKROACH_PORT + Database: zitadel # ZITADEL_SOURCE_COCKROACH_DATABASE + MaxOpenConns: 6 # ZITADEL_SOURCE_COCKROACH_MAXOPENCONNS + MaxIdleConns: 6 # ZITADEL_SOURCE_COCKROACH_MAXIDLECONNS + MaxConnLifetime: 30m # ZITADEL_SOURCE_COCKROACH_MAXCONNLIFETIME + MaxConnIdleTime: 5m # ZITADEL_SOURCE_COCKROACH_MAXCONNIDLETIME + Options: "" # ZITADEL_SOURCE_COCKROACH_OPTIONS User: - Username: zitadel # ZITADEL_DATABASE_COCKROACH_USER_USERNAME - Password: "" # ZITADEL_DATABASE_COCKROACH_USER_PASSWORD + Username: zitadel # ZITADEL_SOURCE_COCKROACH_USER_USERNAME + Password: "" # ZITADEL_SOURCE_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 + Mode: disable # ZITADEL_SOURCE_COCKROACH_USER_SSL_MODE + RootCert: "" # ZITADEL_SOURCE_COCKROACH_USER_SSL_ROOTCERT + Cert: "" # ZITADEL_SOURCE_COCKROACH_USER_SSL_CERT + Key: "" # ZITADEL_SOURCE_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 + Host: # ZITADEL_SOURCE_POSTGRES_HOST + Port: # ZITADEL_SOURCE_POSTGRES_PORT + Database: # ZITADEL_SOURCE_POSTGRES_DATABASE + MaxOpenConns: # ZITADEL_SOURCE_POSTGRES_MAXOPENCONNS + MaxIdleConns: # ZITADEL_SOURCE_POSTGRES_MAXIDLECONNS + MaxConnLifetime: # ZITADEL_SOURCE_POSTGRES_MAXCONNLIFETIME + MaxConnIdleTime: # ZITADEL_SOURCE_POSTGRES_MAXCONNIDLETIME + Options: # ZITADEL_SOURCE_POSTGRES_OPTIONS User: - Username: # ZITADEL_DATABASE_POSTGRES_USER_USERNAME - Password: # ZITADEL_DATABASE_POSTGRES_USER_PASSWORD + Username: # ZITADEL_SOURCE_POSTGRES_USER_USERNAME + Password: # ZITADEL_SOURCE_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 + Mode: # ZITADEL_SOURCE_POSTGRES_USER_SSL_MODE + RootCert: # ZITADEL_SOURCE_POSTGRES_USER_SSL_ROOTCERT + Cert: # ZITADEL_SOURCE_POSTGRES_USER_SSL_CERT + Key: # ZITADEL_SOURCE_POSTGRES_USER_SSL_KEY Destination: postgres: - Host: localhost # ZITADEL_DATABASE_POSTGRES_HOST - Port: 5432 # ZITADEL_DATABASE_POSTGRES_PORT - Database: zitadel # ZITADEL_DATABASE_POSTGRES_DATABASE - MaxOpenConns: 5 # ZITADEL_DATABASE_POSTGRES_MAXOPENCONNS - MaxIdleConns: 2 # ZITADEL_DATABASE_POSTGRES_MAXIDLECONNS - MaxConnLifetime: 30m # ZITADEL_DATABASE_POSTGRES_MAXCONNLIFETIME - MaxConnIdleTime: 5m # ZITADEL_DATABASE_POSTGRES_MAXCONNIDLETIME - Options: "" # ZITADEL_DATABASE_POSTGRES_OPTIONS + Host: localhost # ZITADEL_DESTINATION_POSTGRES_HOST + Port: 5432 # ZITADEL_DESTINATION_POSTGRES_PORT + Database: zitadel # ZITADEL_DESTINATION_POSTGRES_DATABASE + MaxOpenConns: 5 # ZITADEL_DESTINATION_POSTGRES_MAXOPENCONNS + MaxIdleConns: 2 # ZITADEL_DESTINATION_POSTGRES_MAXIDLECONNS + MaxConnLifetime: 30m # ZITADEL_DESTINATION_POSTGRES_MAXCONNLIFETIME + MaxConnIdleTime: 5m # ZITADEL_DESTINATION_POSTGRES_MAXCONNIDLETIME + Options: "" # ZITADEL_DESTINATION_POSTGRES_OPTIONS User: - Username: zitadel # ZITADEL_DATABASE_POSTGRES_USER_USERNAME - Password: "" # ZITADEL_DATABASE_POSTGRES_USER_PASSWORD + Username: zitadel # ZITADEL_DESTINATION_POSTGRES_USER_USERNAME + Password: "" # ZITADEL_DESTINATION_POSTGRES_USER_PASSWORD SSL: - Mode: disable # 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 + Mode: disable # ZITADEL_DESTINATION_POSTGRES_USER_SSL_MODE + RootCert: "" # ZITADEL_DESTINATION_POSTGRES_USER_SSL_ROOTCERT + Cert: "" # ZITADEL_DESTINATION_POSTGRES_USER_SSL_CERT + Key: "" # ZITADEL_DESTINATION_POSTGRES_USER_SSL_KEY -EventBulkSize: 10000 +EventBulkSize: 10000 # ZITADEL_EVENTBULKSIZE +# The maximum duration an auth request was last updated before it gets ignored. +# Default is 30 days +MaxAuthRequestAge: 720h # ZITADEL_MAXAUTHREQUESTAGE Projections: # The maximum duration a transaction remains open @@ -64,14 +67,14 @@ Projections: TransactionDuration: 0s # ZITADEL_PROJECTIONS_TRANSACTIONDURATION # turn off scheduler during operation RequeueEvery: 0s - ConcurrentInstances: 7 - EventBulkLimit: 1000 - Customizations: + ConcurrentInstances: 7 # ZITADEL_PROJECTIONS_CONCURRENTINSTANCES + EventBulkLimit: 1000 # ZITADEL_PROJECTIONS_EVENTBULKLIMIT + Customizations: notifications: MaxFailureCount: 1 Eventstore: - MaxRetries: 3 + MaxRetries: 3 # ZITADEL_EVENTSTORE_MAXRETRIES Auth: Spooler: diff --git a/cmd/mirror/event_store.go b/cmd/mirror/event_store.go index 8ce53b150a..41c529c025 100644 --- a/cmd/mirror/event_store.go +++ b/cmd/mirror/event_store.go @@ -69,6 +69,7 @@ func positionQuery(db *db.DB) string { } func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { + logging.Info("starting to copy events") start := time.Now() reader, writer := io.Pipe() @@ -130,7 +131,10 @@ func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { if err != nil { return zerrors.ThrowUnknownf(err, "MIGRA-KTuSq", "unable to copy events from source during iteration %d", i) } + logging.WithFields("batch_count", i).Info("batch of events copied") + if tag.RowsAffected() < int64(bulkSize) { + logging.WithFields("batch_count", i).Info("last batch of events copied") return nil } @@ -202,6 +206,7 @@ func writeCopyEventsDone(ctx context.Context, es *eventstore.EventStore, id, sou } func copyUniqueConstraints(ctx context.Context, source, dest *db.DB) { + logging.Info("starting to copy unique constraints") start := time.Now() reader, writer := io.Pipe() errs := make(chan error, 1) diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index 66b3fb1a26..4e12b29748 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -3,6 +3,7 @@ package mirror import ( "context" "database/sql" + "fmt" "net/http" "sync" "time" @@ -104,6 +105,7 @@ func projections( config *ProjectionsConfig, masterKey string, ) { + logging.Info("starting to fill projections") start := time.Now() client, err := database.Connect(config.Destination, false) @@ -255,8 +257,10 @@ func projections( go execProjections(ctx, instances, failedInstances, &wg) } - for _, instance := range queryInstanceIDs(ctx, client) { + existingInstances := queryInstanceIDs(ctx, client) + for i, instance := range existingInstances { instances <- instance + logging.WithFields("id", instance, "index", fmt.Sprintf("%d/%d", i, len(existingInstances))).Info("instance queued for projection") } close(instances) wg.Wait() @@ -268,7 +272,7 @@ func projections( 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") + logging.WithFields("instance", instance).Info("starting projections") ctx = internal_authz.WithInstanceID(ctx, instance) err := projection.ProjectInstance(ctx) @@ -311,7 +315,7 @@ func execProjections(ctx context.Context, instances <-chan string, failedInstanc wg.Done() } -// returns the instance configured by flag +// queryInstanceIDs 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 { diff --git a/cmd/mirror/system.go b/cmd/mirror/system.go index 00b48eb491..57eb205436 100644 --- a/cmd/mirror/system.go +++ b/cmd/mirror/system.go @@ -46,6 +46,7 @@ func copySystem(ctx context.Context, config *Migration) { } func copyAssets(ctx context.Context, source, dest *database.DB) { + logging.Info("starting to copy assets") start := time.Now() sourceConn, err := source.Conn(ctx) @@ -70,7 +71,7 @@ func copyAssets(ctx context.Context, source, dest *database.DB) { logging.OnError(err).Fatal("unable to acquire dest connection") defer destConn.Close() - var eventCount int64 + var assetCount int64 err = destConn.Raw(func(driverConn interface{}) error { conn := driverConn.(*stdlib.Conn).Conn() @@ -82,16 +83,17 @@ func copyAssets(ctx context.Context, source, dest *database.DB) { } 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() + assetCount = 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") + logging.WithFields("took", time.Since(start), "count", assetCount).Info("assets migrated") } func copyEncryptionKeys(ctx context.Context, source, dest *database.DB) { + logging.Info("starting to copy encryption keys") start := time.Now() sourceConn, err := source.Conn(ctx) @@ -116,7 +118,7 @@ func copyEncryptionKeys(ctx context.Context, source, dest *database.DB) { logging.OnError(err).Fatal("unable to acquire dest connection") defer destConn.Close() - var eventCount int64 + var keyCount int64 err = destConn.Raw(func(driverConn interface{}) error { conn := driverConn.(*stdlib.Conn).Conn() @@ -128,11 +130,11 @@ func copyEncryptionKeys(ctx context.Context, source, dest *database.DB) { } tag, err := conn.PgConn().CopyFrom(ctx, r, "COPY system.encryption_keys FROM stdin") - eventCount = tag.RowsAffected() + keyCount = 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") + logging.WithFields("took", time.Since(start), "count", keyCount).Info("encryption keys migrated") } diff --git a/docs/docs/self-hosting/manage/cli/mirror.mdx b/docs/docs/self-hosting/manage/cli/mirror.mdx index 45bac9b279..ae81800e39 100644 --- a/docs/docs/self-hosting/manage/cli/mirror.mdx +++ b/docs/docs/self-hosting/manage/cli/mirror.mdx @@ -158,6 +158,9 @@ Destination: # As cockroachdb first copies the data into memory this parameter is used to iterate through the events table and fetch only the given amount of events per iteration EventBulkSize: 10000 # ZITADEL_EVENTBULKSIZE +# The maximum duration an auth request was last updated before it gets ignored. +# Default is 30 days +MaxAuthRequestAge: 720h # ZITADEL_MAXAUTHREQUESTAGE Projections: # Defines how many projections are allowed to run in parallel diff --git a/internal/admin/repository/eventsourcing/handler/handler.go b/internal/admin/repository/eventsourcing/handler/handler.go index ec268c25a1..76584b55b0 100644 --- a/internal/admin/repository/eventsourcing/handler/handler.go +++ b/internal/admin/repository/eventsourcing/handler/handler.go @@ -2,9 +2,13 @@ package handler import ( "context" + "fmt" "time" + "github.com/zitadel/logging" + "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/view" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" @@ -57,11 +61,13 @@ func Start(ctx context.Context) { } func ProjectInstance(ctx context.Context) error { - for _, projection := range projections { + for i, projection := range projections { + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting admin projection") _, err := projection.Trigger(ctx) if err != nil { return err } + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("admin projection done") } return nil } diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go index 0d87ab06bb..74a27a8312 100644 --- a/internal/auth/repository/eventsourcing/handler/handler.go +++ b/internal/auth/repository/eventsourcing/handler/handler.go @@ -2,8 +2,12 @@ package handler import ( "context" + "fmt" "time" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" @@ -72,11 +76,13 @@ func Projections() []*handler2.Handler { } func ProjectInstance(ctx context.Context) error { - for _, projection := range projections { + for i, projection := range projections { + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting auth projection") _, err := projection.Trigger(ctx) if err != nil { return err } + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("auth projection done") } return nil } diff --git a/internal/notification/projections.go b/internal/notification/projections.go index a2d4d4140e..9b6b975fa1 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -2,8 +2,12 @@ package notification import ( "context" + "fmt" "time" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" @@ -68,11 +72,13 @@ func Start(ctx context.Context) { } func ProjectInstance(ctx context.Context) error { - for _, projection := range projections { + for i, projection := range projections { + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting notification projection") _, err := projection.Trigger(ctx) if err != nil { return err } + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("notification projection done") } return nil } diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index f4e3bbe0d4..07953a27e8 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -2,6 +2,9 @@ package projection import ( "context" + "fmt" + + "github.com/zitadel/logging" internal_authz "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" @@ -90,6 +93,7 @@ var ( ) type projection interface { + ProjectionName() string Start(ctx context.Context) Init(ctx context.Context) error Trigger(ctx context.Context, opts ...handler.TriggerOpt) (_ context.Context, err error) @@ -206,21 +210,25 @@ func Start(ctx context.Context) { } func ProjectInstance(ctx context.Context) error { - for _, projection := range projections { + for i, projection := range projections { + logging.WithFields("name", projection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting projection") _, err := projection.Trigger(ctx) if err != nil { return err } + logging.WithFields("name", projection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID()).Info("projection done") } return nil } func ProjectInstanceFields(ctx context.Context) error { - for _, fieldProjection := range fields { + for i, fieldProjection := range fields { + logging.WithFields("name", fieldProjection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(fields))).Info("starting fields projection") err := fieldProjection.Trigger(ctx) if err != nil { return err } + logging.WithFields("name", fieldProjection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID()).Info("fields projection done") } return nil } From 0465d5093ef009e9bbea6998ca383bcb20144136 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 30 Apr 2025 10:26:04 +0200 Subject: [PATCH 029/123] fix(features): remove the improved performance enumer (#9819) # Which Problems Are Solved Instance that had improved performance flags set, got event errors when getting instance features. This is because the improved performance flags were marshalled using the enumerated integers, but now needed to be unmashalled using the added UnmarshallText method. # How the Problems Are Solved - Remove emnumer generation # Additional Changes - none # Additional Context - reported on QA - Backport to next-rc / v3 --- cmd/defaults.yaml | 13 ++- internal/feature/feature.go | 3 +- .../feature/improvedperformancetype_enumer.go | 106 ------------------ 3 files changed, 9 insertions(+), 113 deletions(-) delete mode 100644 internal/feature/improvedperformancetype_enumer.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index f20fbc03fc..6ab01ab35b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1102,12 +1102,13 @@ DefaultInstance: # LegacyIntrospection: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LEGACYINTROSPECTION # UserSchema: false # ZITADEL_DEFAULTINSTANCE_FEATURES_USERSCHEMA # TokenExchange: false # ZITADEL_DEFAULTINSTANCE_FEATURES_TOKENEXCHANGE - # ImprovedPerformance: # ZITADEL_DEFAULTINSTANCE_FEATURES_IMPROVEDPERFORMANCE - # - OrgByID - # - ProjectGrant - # - Project - # - UserGrant - # - OrgDomainVerified + ImprovedPerformance: # ZITADEL_DEFAULTINSTANCE_FEATURES_IMPROVEDPERFORMANCE + # https://github.com/zitadel/zitadel/blob/main/internal/feature/feature.go#L64-L68 + # - 1 # OrgByID + # - 2 # ProjectGrant + # - 3 # Project + # - 4 # UserGrant + # - 5 # OrgDomainVerified # WebKey: false # ZITADEL_DEFAULTINSTANCE_FEATURES_WEBKEY # DebugOIDCParentError: false # ZITADEL_DEFAULTINSTANCE_FEATURES_DEBUGOIDCPARENTERROR # OIDCSingleV1SessionTermination: false # ZITADEL_DEFAULTINSTANCE_FEATURES_OIDCSINGLEV1SESSIONTERMINATION diff --git a/internal/feature/feature.go b/internal/feature/feature.go index f500b80eb3..b5f5a901d4 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -57,7 +57,8 @@ type Features struct { ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` } -//go:generate enumer -type ImprovedPerformanceType -trimprefix ImprovedPerformanceType -text +/* Note: do not generate the stringer or enumer for this type, is it breaks existing events */ + type ImprovedPerformanceType int32 const ( diff --git a/internal/feature/improvedperformancetype_enumer.go b/internal/feature/improvedperformancetype_enumer.go deleted file mode 100644 index a12673c205..0000000000 --- a/internal/feature/improvedperformancetype_enumer.go +++ /dev/null @@ -1,106 +0,0 @@ -// Code generated by "enumer -type ImprovedPerformanceType -trimprefix ImprovedPerformanceType -text"; DO NOT EDIT. - -package feature - -import ( - "fmt" - "strings" -) - -const _ImprovedPerformanceTypeName = "UnspecifiedOrgByIDProjectGrantProjectUserGrantOrgDomainVerified" - -var _ImprovedPerformanceTypeIndex = [...]uint8{0, 11, 18, 30, 37, 46, 63} - -const _ImprovedPerformanceTypeLowerName = "unspecifiedorgbyidprojectgrantprojectusergrantorgdomainverified" - -func (i ImprovedPerformanceType) String() string { - if i < 0 || i >= ImprovedPerformanceType(len(_ImprovedPerformanceTypeIndex)-1) { - return fmt.Sprintf("ImprovedPerformanceType(%d)", i) - } - return _ImprovedPerformanceTypeName[_ImprovedPerformanceTypeIndex[i]:_ImprovedPerformanceTypeIndex[i+1]] -} - -// An "invalid array index" compiler error signifies that the constant values have changed. -// Re-run the stringer command to generate them again. -func _ImprovedPerformanceTypeNoOp() { - var x [1]struct{} - _ = x[ImprovedPerformanceTypeUnspecified-(0)] - _ = x[ImprovedPerformanceTypeOrgByID-(1)] - _ = x[ImprovedPerformanceTypeProjectGrant-(2)] - _ = x[ImprovedPerformanceTypeProject-(3)] - _ = x[ImprovedPerformanceTypeUserGrant-(4)] - _ = x[ImprovedPerformanceTypeOrgDomainVerified-(5)] -} - -var _ImprovedPerformanceTypeValues = []ImprovedPerformanceType{ImprovedPerformanceTypeUnspecified, ImprovedPerformanceTypeOrgByID, ImprovedPerformanceTypeProjectGrant, ImprovedPerformanceTypeProject, ImprovedPerformanceTypeUserGrant, ImprovedPerformanceTypeOrgDomainVerified} - -var _ImprovedPerformanceTypeNameToValueMap = map[string]ImprovedPerformanceType{ - _ImprovedPerformanceTypeName[0:11]: ImprovedPerformanceTypeUnspecified, - _ImprovedPerformanceTypeLowerName[0:11]: ImprovedPerformanceTypeUnspecified, - _ImprovedPerformanceTypeName[11:18]: ImprovedPerformanceTypeOrgByID, - _ImprovedPerformanceTypeLowerName[11:18]: ImprovedPerformanceTypeOrgByID, - _ImprovedPerformanceTypeName[18:30]: ImprovedPerformanceTypeProjectGrant, - _ImprovedPerformanceTypeLowerName[18:30]: ImprovedPerformanceTypeProjectGrant, - _ImprovedPerformanceTypeName[30:37]: ImprovedPerformanceTypeProject, - _ImprovedPerformanceTypeLowerName[30:37]: ImprovedPerformanceTypeProject, - _ImprovedPerformanceTypeName[37:46]: ImprovedPerformanceTypeUserGrant, - _ImprovedPerformanceTypeLowerName[37:46]: ImprovedPerformanceTypeUserGrant, - _ImprovedPerformanceTypeName[46:63]: ImprovedPerformanceTypeOrgDomainVerified, - _ImprovedPerformanceTypeLowerName[46:63]: ImprovedPerformanceTypeOrgDomainVerified, -} - -var _ImprovedPerformanceTypeNames = []string{ - _ImprovedPerformanceTypeName[0:11], - _ImprovedPerformanceTypeName[11:18], - _ImprovedPerformanceTypeName[18:30], - _ImprovedPerformanceTypeName[30:37], - _ImprovedPerformanceTypeName[37:46], - _ImprovedPerformanceTypeName[46:63], -} - -// ImprovedPerformanceTypeString retrieves an enum value from the enum constants string name. -// Throws an error if the param is not part of the enum. -func ImprovedPerformanceTypeString(s string) (ImprovedPerformanceType, error) { - if val, ok := _ImprovedPerformanceTypeNameToValueMap[s]; ok { - return val, nil - } - - if val, ok := _ImprovedPerformanceTypeNameToValueMap[strings.ToLower(s)]; ok { - return val, nil - } - return 0, fmt.Errorf("%s does not belong to ImprovedPerformanceType values", s) -} - -// ImprovedPerformanceTypeValues returns all values of the enum -func ImprovedPerformanceTypeValues() []ImprovedPerformanceType { - return _ImprovedPerformanceTypeValues -} - -// ImprovedPerformanceTypeStrings returns a slice of all String values of the enum -func ImprovedPerformanceTypeStrings() []string { - strs := make([]string, len(_ImprovedPerformanceTypeNames)) - copy(strs, _ImprovedPerformanceTypeNames) - return strs -} - -// IsAImprovedPerformanceType returns "true" if the value is listed in the enum definition. "false" otherwise -func (i ImprovedPerformanceType) IsAImprovedPerformanceType() bool { - for _, v := range _ImprovedPerformanceTypeValues { - if i == v { - return true - } - } - return false -} - -// MarshalText implements the encoding.TextMarshaler interface for ImprovedPerformanceType -func (i ImprovedPerformanceType) MarshalText() ([]byte, error) { - return []byte(i.String()), nil -} - -// UnmarshalText implements the encoding.TextUnmarshaler interface for ImprovedPerformanceType -func (i *ImprovedPerformanceType) UnmarshalText(text []byte) error { - var err error - *i, err = ImprovedPerformanceTypeString(string(text)) - return err -} From 3953879fe9c2533289dab8a67a5e1a0514500150 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 30 Apr 2025 11:12:48 +0200 Subject: [PATCH 030/123] fix: correct unmarshalling of IdP user when using Google (#9799) # Which Problems Are Solved Users from Google IDP's are not unmarshalled correctly in intent endpoints and not returned to callers. # How the Problems Are Solved Provided correct type for unmarshalling of the information. # Additional Changes None # Additional Context None --------- Co-authored-by: Livio Spring --- internal/api/grpc/user/v2/intent.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index 6e46dfd5c3..06966edb35 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -182,7 +182,7 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R case *gitlab.Provider: idpUser, err = unmarshalIdpUser(intent.IDPUser, &oidc.User{UserInfo: &oidc_pkg.UserInfo{}}) case *google.Provider: - idpUser, err = unmarshalIdpUser(intent.IDPUser, &oidc.User{UserInfo: &oidc_pkg.UserInfo{}}) + idpUser, err = unmarshalIdpUser(intent.IDPUser, &google.User{User: &oidc.User{UserInfo: &oidc_pkg.UserInfo{}}}) case *saml.Provider: idpUser, err = unmarshalIdpUser(intent.IDPUser, &saml.UserMapper{}) case *ldap.Provider: From 002c3eb025693b51b99670d05cd50953b4c8ca48 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 30 Apr 2025 13:16:44 +0200 Subject: [PATCH 031/123] fix: Use ID ordering for the executions in Actions v2 (#9820) # Which Problems Are Solved Sort Executions by ID in the Actions V2 view. This way All is the first element in the table. # How the Problems Are Solved Pass ID sorting to the Backend. # Additional Changes Cleaned up some imports. # Additional Context - Part of Make actions sortable by hirarchie #9688 --- .../actions-two-actions/actions-two-actions.component.ts | 3 ++- console/src/app/services/grpc.service.ts | 5 ----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts index b5f2260e34..7e0d457dd5 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions.component.ts @@ -16,6 +16,7 @@ import { MessageInitShape } from '@bufbuild/protobuf'; import { SetExecutionRequestSchema } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; import { Target } from '@zitadel/proto/zitadel/action/v2beta/target_pb'; import { InfoSectionType } from '../../info-section/info-section.component'; +import { ExecutionFieldName } from '@zitadel/proto/zitadel/action/v2beta/query_pb'; @Component({ selector: 'cnsl-actions-two-actions', @@ -42,7 +43,7 @@ export class ActionsTwoActionsComponent { return this.refresh$.pipe( startWith(true), switchMap(() => { - return this.actionService.listExecutions({}); + return this.actionService.listExecutions({ sortingColumn: ExecutionFieldName.ID, pagination: { asc: true } }); }), map(({ result }) => result.map(correctlyTypeExecution)), catchError((err) => { diff --git a/console/src/app/services/grpc.service.ts b/console/src/app/services/grpc.service.ts index b2f89ca648..d2add12f41 100644 --- a/console/src/app/services/grpc.service.ts +++ b/console/src/app/services/grpc.service.ts @@ -15,7 +15,6 @@ import { AuthInterceptor, AuthInterceptorProvider, NewConnectWebAuthInterceptor import { ExhaustedGrpcInterceptor } from './interceptors/exhausted.grpc.interceptor'; import { I18nInterceptor } from './interceptors/i18n.interceptor'; import { NewConnectWebOrgInterceptor, OrgInterceptor, OrgInterceptorProvider } from './interceptors/org.interceptor'; -import { StorageService } from './storage.service'; import { UserServiceClient } from '../proto/generated/zitadel/user/v2/User_serviceServiceClientPb'; //@ts-ignore import { createFeatureServiceClient, createUserServiceClient, createSessionServiceClient } from '@zitadel/client/v2'; @@ -24,14 +23,10 @@ import { createAuthServiceClient, createManagementServiceClient } from '@zitadel import { createGrpcWebTransport } from '@connectrpc/connect-web'; // @ts-ignore import { createClientFor } from '@zitadel/client'; -import { Client, Transport } from '@connectrpc/connect'; import { WebKeyService } from '@zitadel/proto/zitadel/webkey/v2beta/webkey_service_pb'; import { ActionService } from '@zitadel/proto/zitadel/action/v2beta/action_service_pb'; -// @ts-ignore -import { createClientFor } from '@zitadel/client'; - const createWebKeyServiceClient = createClientFor(WebKeyService); const createActionServiceClient = createClientFor(ActionService); From 48c1f7e49f47e507e4c31cfc84f8a3a043278969 Mon Sep 17 00:00:00 2001 From: Ramon Date: Wed, 30 Apr 2025 14:22:27 +0200 Subject: [PATCH 032/123] fix: Actions V2 improve deleted target handling in executions (#9822) # Which Problems Are Solved Previously, if a target was deleted but still referenced by an execution, it became impossible to load the executions. # How the Problems Are Solved Missing targets in the execution table are now gracefully ignored, allowing executions to load without errors. # Additional Changes Enhanced permission handling in the settings sidenav to ensure users have the correct access rights. --- .../actions-two-actions-table.component.html | 4 ++-- .../actions-two-actions-table.component.ts | 10 +++------- console/src/app/modules/settings-list/settings.ts | 6 ++---- console/src/app/services/grpc-auth.service.ts | 15 +++++++++++++-- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html index 82f04fb124..7948ba7554 100644 --- a/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html +++ b/console/src/app/modules/actions-two/actions-two-actions/actions-two-actions-table/actions-two-actions-table.component.html @@ -24,8 +24,8 @@
{{ 'ACTIONSTWO.EXECUTION.TABLE.TARGET' | translate }}
- {{ target.name }} + + {{ target.name }}
+ + + + + + { + pii.map((row, rowID) => { + return ( + + + + + + ) + }) + } +
Type of personal dataExamplesAffected data subjects
{row.type}
    {row.examples.map((example) => { return (
  • {example}
  • )})}
{row.subjects}
+ ); +} diff --git a/docs/src/components/subprocessors.jsx b/docs/src/components/subprocessors.jsx deleted file mode 100644 index a6bf10eee8..0000000000 --- a/docs/src/components/subprocessors.jsx +++ /dev/null @@ -1,162 +0,0 @@ -import React from "react"; - -export function SubProcessorTable() { - - const country_list = { - us: "USA", - eu: "EU", - ch: "Switzerland", - fr: "France", - in: "India", - de: "Germany", - ee: "Estonia", - nl: "Netherlands", - ro: "Romania", - } - const processors = [ - { - entity: "Google LLC", - purpose: "Cloud infrastructure provider (Google Cloud), business applications and collaboration (Workspace), Data warehouse services, Content delivery network, DDoS and bot prevention", - hosting: "Region designated by Customer, United States", - country: country_list.us, - enduserdata: "Yes" - }, - { - entity: "Datadog, Inc.", - purpose: "Infrastructure monitoring, log analytics, and alerting", - hosting: country_list.eu, - country: country_list.us, - enduserdata: "Yes (logs)" - }, - { - entity: "Github, Inc.", - purpose: "Source code management, code scanning, dependency management, security advisory, issue management, continuous integration", - hosting: country_list.us, - country: country_list.us, - enduserdata: false - }, - { - entity: "Stripe Payments Europe, Ltd.", - purpose: "Subscription management, payment process", - hosting: country_list.us, - country: country_list.us, - enduserdata: false - }, - { - entity: "Bexio AG", - purpose: "Customer management, payment process", - hosting: country_list.ch, - country: country_list.ch, - enduserdata: false - }, - { - entity: "Mailjet SAS", - purpose: "Marketing automation", - hosting: country_list.eu, - country: country_list.fr, - enduserdata: false - }, - { - entity: "Postmark (AC PM LLC)", - purpose: "Transactional mails, if no customer owned SMTP service is configured", - hosting: country_list.us, - country: country_list.us, - enduserdata: "Yes (opt-out)" - }, - { - entity: "Vercel, Inc.", - purpose: "Website hosting", - hosting: country_list.us, - country: country_list.us, - enduserdata: false - }, - { - entity: "Agolia SAS", - purpose: "Documentation search engine (zitadel.com/docs)", - hosting: country_list.us, - country: country_list.in, - enduserdata: false - }, - { - entity: "Discord Netherlands BV", - purpose: "Community chat (zitadel.com/chat)", - hosting: country_list.us, - country: country_list.us, - enduserdata: false - }, - { - entity: "Statuspal", - purpose: "ZITADEL Cloud service status announcements", - hosting: country_list.us, - country: country_list.de, - enduserdata: false - }, - { - entity: "Plausible Insights OÜ", - purpose: "Privacy-friendly web analytics", - hosting: country_list.de, - country: country_list.ee, - enduserdata: false, - dpa: 'https://plausible.io/dpa' - }, - { - entity: "Twillio Inc.", - purpose: "Messaging platform for SMS", - hosting: country_list.us, - country: country_list.us, - enduserdata: "Yes (opt-out)" - }, - { - entity: "Mohlmann Solutions SRL", - purpose: "Global payroll", - hosting: undefined, - country: country_list.ro, - enduserdata: false - }, - { - entity: "Remote Europe Holding, B.V.", - purpose: "Global payroll", - hosting: undefined, - country: country_list.nl, - enduserdata: false - }, - { - entity: "HubSpot Inc.", - purpose: "Customer and sales management, Marketing automation, Support requests", - hosting: country_list.eu, - country: country_list.us, - enduserdata: false - }, - ] - - return ( - - - - - - - - - { - processors - .sort((a, b) => { - if (a.entity < b.entity) return -1 - if (a.entity > b.entity) return 1 - else return 0 - }) - .map((processor, rowID) => { - return ( - - - - - - - - ) - }) - } -
Entity namePurposeEnd-user dataHosting locationCountry of registration
{processor.entity}{processor.purpose}{processor.enduserdata ? processor.enduserdata : 'No'}{processor.hosting ? processor.hosting : 'n/a'}{processor.country}
- ); -} From d71795c43354ec6ba6134cf0b06ef0ba951b86d0 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 8 May 2025 08:35:34 +0200 Subject: [PATCH 045/123] fix: remove index es_instance_position (#9862) # Which Problems Are Solved #9837 added a new index `es_instance_position` on the events table with the idea to improve performance for some projections. Unfortunately, it makes it worse for almost all projections and would only improve the situation for the events handler of the actions V2 subscriptions. # How the Problems Are Solved Remove the index again. # Additional Changes None # Additional Context relates to #9837 relates to #9863 --- cmd/setup/54.go | 2 +- cmd/setup/54.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/setup/54.go b/cmd/setup/54.go index 3dd2f60abe..9d65264941 100644 --- a/cmd/setup/54.go +++ b/cmd/setup/54.go @@ -23,5 +23,5 @@ func (mig *InstancePositionIndex) Execute(ctx context.Context, _ eventstore.Even } func (mig *InstancePositionIndex) String() string { - return "54_instance_position_index" + return "54_instance_position_index_remove" } diff --git a/cmd/setup/54.sql b/cmd/setup/54.sql index 1dca8c7575..927bd2aa9b 100644 --- a/cmd/setup/54.sql +++ b/cmd/setup/54.sql @@ -1 +1 @@ -CREATE INDEX CONCURRENTLY IF NOT EXISTS es_instance_position ON eventstore.events2 (instance_id, position); +DROP INDEX IF EXISTS eventstore.es_instance_position; From 867e9cb15a92786a5953a84beefcb85e296e80ba Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 8 May 2025 09:32:41 +0200 Subject: [PATCH 046/123] fix: correctly use single matching user (by loginname) (#9865) # Which Problems Are Solved In rare cases there was a possibility that multiple users were found by a loginname. This prevented the corresponding user to sign in. # How the Problems Are Solved Fixed the corresponding query (to correctly respect the org domain policy). # Additional Changes None # Additional Context Found during the investigation of a support request --- internal/query/user_by_login_name.sql | 2 +- internal/query/user_notify_by_login_name.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/query/user_by_login_name.sql b/internal/query/user_by_login_name.sql index a37c213612..f7d2fd3e23 100644 --- a/internal/query/user_by_login_name.sql +++ b/internal/query/user_by_login_name.sql @@ -10,7 +10,7 @@ WITH found_users AS ( LEFT JOIN projections.login_names3_policies p_custom ON u.instance_id = p_custom.instance_id AND p_custom.instance_id = $4 AND p_custom.resource_owner = u.resource_owner - LEFT JOIN projections.login_names3_policies p_default + JOIN projections.login_names3_policies p_default ON u.instance_id = p_default.instance_id AND p_default.instance_id = $4 AND p_default.is_default IS TRUE AND ( diff --git a/internal/query/user_notify_by_login_name.sql b/internal/query/user_notify_by_login_name.sql index 5b23cd61a7..090a8991f9 100644 --- a/internal/query/user_notify_by_login_name.sql +++ b/internal/query/user_notify_by_login_name.sql @@ -10,7 +10,7 @@ WITH found_users AS ( LEFT JOIN projections.login_names3_policies p_custom ON u.instance_id = p_custom.instance_id AND p_custom.instance_id = $4 AND p_custom.resource_owner = u.resource_owner - LEFT JOIN projections.login_names3_policies p_default + JOIN projections.login_names3_policies p_default ON u.instance_id = p_default.instance_id AND p_default.instance_id = $4 AND p_default.is_default IS TRUE AND ( From 60ce32ca4fbd818a64524294653923e0ffa81688 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Thu, 8 May 2025 17:13:57 +0200 Subject: [PATCH 047/123] fix(setup): reenable index creation (#9868) # Which Problems Are Solved We saw high CPU usage if many events were created on the database. This was caused by the new actions which query for all event types and aggregate types. # How the Problems Are Solved - the handler of action execution does not filter for aggregate and event types. - the index for `instance_id` and `position` is reenabled. # Additional Changes none # Additional Context none --- cmd/setup/54.go | 2 +- cmd/setup/54.sql | 2 +- internal/eventstore/handler/v2/handler.go | 14 ++++++++++++++ internal/execution/handlers.go | 3 +++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/cmd/setup/54.go b/cmd/setup/54.go index 9d65264941..e4a2e43862 100644 --- a/cmd/setup/54.go +++ b/cmd/setup/54.go @@ -23,5 +23,5 @@ func (mig *InstancePositionIndex) Execute(ctx context.Context, _ eventstore.Even } func (mig *InstancePositionIndex) String() string { - return "54_instance_position_index_remove" + return "54_instance_position_index_again" } diff --git a/cmd/setup/54.sql b/cmd/setup/54.sql index 927bd2aa9b..1dca8c7575 100644 --- a/cmd/setup/54.sql +++ b/cmd/setup/54.sql @@ -1 +1 @@ -DROP INDEX IF EXISTS eventstore.es_instance_position; +CREATE INDEX CONCURRENTLY IF NOT EXISTS es_instance_position ON eventstore.events2 (instance_id, position); diff --git a/internal/eventstore/handler/v2/handler.go b/internal/eventstore/handler/v2/handler.go index 43c3e58b3b..fb696ad090 100644 --- a/internal/eventstore/handler/v2/handler.go +++ b/internal/eventstore/handler/v2/handler.go @@ -60,6 +60,7 @@ type Handler struct { requeueEvery time.Duration txDuration time.Duration now nowFunc + queryGlobal bool triggeredInstancesSync sync.Map @@ -143,6 +144,11 @@ type Projection interface { Reducers() []AggregateReducer } +type GlobalProjection interface { + Projection + FilterGlobalEvents() +} + func NewHandler( ctx context.Context, config *Config, @@ -185,6 +191,10 @@ func NewHandler( metrics: metrics, } + if _, ok := projection.(GlobalProjection); ok { + handler.queryGlobal = true + } + return handler } @@ -676,6 +686,10 @@ func (h *Handler) eventQuery(currentState *state) *eventstore.SearchQueryBuilder } } + if h.queryGlobal { + return builder + } + aggregateTypes := make([]eventstore.AggregateType, 0, len(h.eventTypes)) eventTypes := make([]eventstore.EventType, 0, len(h.eventTypes)) diff --git a/internal/execution/handlers.go b/internal/execution/handlers.go index 7ffb4cc6ff..030e6d5186 100644 --- a/internal/execution/handlers.go +++ b/internal/execution/handlers.go @@ -84,6 +84,9 @@ func (u *eventHandler) Reducers() []handler.AggregateReducer { return aggReducers } +// FilterGlobalEvents implements [handler.GlobalProjection] +func (u *eventHandler) FilterGlobalEvents() {} + func groupsFromEventType(s string) []string { parts := strings.Split(s, ".") groups := make([]string, len(parts)) From 28856015d6116d1989c9c3d95e85d10df7068c2a Mon Sep 17 00:00:00 2001 From: subaru <79771445+subaru-hello@users.noreply.github.com> Date: Mon, 12 May 2025 17:04:32 +0900 Subject: [PATCH 048/123] feat(console): Add organization ID filter to organization list (#9823) # Which Problems Are Solved Replace this example text with a concise list of problems that this PR solves. - Organization list lacked the ability to filter by organization ID - No efficient method was provided for users to search organizations by ID # How the Problems Are Solved Replace this example text with a concise list of changes that this PR introduces. - Added organization ID filtering functionality to `filter-org.component.ts` - Added `ID` to the `SubQuery` enum - Added `ID` case handling to `changeCheckbox`, `setValue`, and `getSubFilter` methods - Added ID filter UI to `filter-org.component.html` - Added checkbox and text input field - Used translation key to display "Organization ID" label - Added new translation key to translation file (`en.json`) - Added `FILTER.ORGID` key with "Organization ID" value # 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. - Maintained consistency with existing filtering functionality - Ensured intuitive user interface usability - Added new key while maintaining translation file structure # 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 #8792 - Discussion #xxx - Follow-up for PR #xxx - https://discord.com/channels/xxx/xxx --------- Co-authored-by: Marco A. --- .../filter-org/filter-org.component.html | 15 +++++++ .../filter-org/filter-org.component.ts | 40 ++++++++++++++++++- console/src/assets/i18n/bg.json | 2 + console/src/assets/i18n/cs.json | 2 + console/src/assets/i18n/de.json | 2 + console/src/assets/i18n/en.json | 8 ++-- console/src/assets/i18n/es.json | 2 + console/src/assets/i18n/fr.json | 2 + console/src/assets/i18n/hu.json | 2 + console/src/assets/i18n/id.json | 2 + console/src/assets/i18n/it.json | 2 + console/src/assets/i18n/ja.json | 2 + console/src/assets/i18n/ko.json | 2 + console/src/assets/i18n/mk.json | 2 + console/src/assets/i18n/nl.json | 2 + console/src/assets/i18n/pl.json | 2 + console/src/assets/i18n/pt.json | 2 + console/src/assets/i18n/ro.json | 2 + console/src/assets/i18n/ru.json | 2 + console/src/assets/i18n/sv.json | 2 + console/src/assets/i18n/zh.json | 2 + 21 files changed, 95 insertions(+), 4 deletions(-) diff --git a/console/src/app/modules/filter-org/filter-org.component.html b/console/src/app/modules/filter-org/filter-org.component.html index 4e8535fe76..ae42667f49 100644 --- a/console/src/app/modules/filter-org/filter-org.component.html +++ b/console/src/app/modules/filter-org/filter-org.component.html @@ -69,4 +69,19 @@
+ +
+ {{ 'FILTER.ORGID' | translate }} + +
+ + {{ 'FILTER.METHODS.1' | translate }} + + + + + +
+
diff --git a/console/src/app/modules/filter-org/filter-org.component.ts b/console/src/app/modules/filter-org/filter-org.component.ts index 220b219358..4ea9a6ea6e 100644 --- a/console/src/app/modules/filter-org/filter-org.component.ts +++ b/console/src/app/modules/filter-org/filter-org.component.ts @@ -3,7 +3,14 @@ import { MatCheckboxChange } from '@angular/material/checkbox'; import { ActivatedRoute, Router } from '@angular/router'; import { take } from 'rxjs'; import { TextQueryMethod } from 'src/app/proto/generated/zitadel/object_pb'; -import { OrgDomainQuery, OrgNameQuery, OrgQuery, OrgState, OrgStateQuery } from 'src/app/proto/generated/zitadel/org_pb'; +import { + OrgDomainQuery, + OrgNameQuery, + OrgQuery, + OrgState, + OrgStateQuery, + OrgIDQuery, +} from 'src/app/proto/generated/zitadel/org_pb'; import { UserNameQuery } from 'src/app/proto/generated/zitadel/user_pb'; import { FilterComponent } from '../filter/filter.component'; @@ -12,6 +19,7 @@ enum SubQuery { NAME, STATE, DOMAIN, + ID, } @Component({ @@ -61,6 +69,12 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { orgDomainQuery.setMethod(filter.domainQuery.method); orgQuery.setDomainQuery(orgDomainQuery); return orgQuery; + } else if (filter.idQuery) { + const orgQuery = new OrgQuery(); + const orgIdQuery = new OrgIDQuery(); + orgIdQuery.setId(filter.idQuery.id); + orgQuery.setIdQuery(orgIdQuery); + return orgQuery; } else { return undefined; } @@ -100,6 +114,13 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { odq.setDomainQuery(dq); this.searchQueries.push(odq); break; + case SubQuery.ID: + const idq = new OrgIDQuery(); + idq.setId(''); + const oidq = new OrgQuery(); + oidq.setIdQuery(idq); + this.searchQueries.push(oidq); + break; } } else { switch (subquery) { @@ -121,6 +142,12 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { this.searchQueries.splice(index_pdn, 1); } break; + case SubQuery.ID: + const index_id = this.searchQueries.findIndex((q) => (q as OrgQuery).toObject().idQuery !== undefined); + if (index_id > -1) { + this.searchQueries.splice(index_id, 1); + } + break; } } } @@ -140,6 +167,10 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { (query as OrgDomainQuery).setDomain(value); this.filterChanged.emit(this.searchQueries ? this.searchQueries : []); break; + case SubQuery.ID: + (query as OrgIDQuery).setId(value); + this.filterChanged.emit(this.searchQueries ? this.searchQueries : []); + break; } } @@ -166,6 +197,13 @@ export class FilterOrgComponent extends FilterComponent implements OnInit { } else { return undefined; } + case SubQuery.ID: + const id = this.searchQueries.find((q) => (q as OrgQuery).toObject().idQuery !== undefined); + if (id) { + return (id as OrgQuery).getIdQuery(); + } else { + return undefined; + } } } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index b98204a917..b56f5797ec 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -692,6 +692,7 @@ "EMAIL": "електронна поща", "USERNAME": "Потребителско име", "ORGNAME": "Наименование на организацията", + "ORGID": "Идентификатор на организацията", "PRIMARYDOMAIN": "Основен домейн", "PROJECTNAME": "Име на проекта", "RESOURCEOWNER": "Собственик на ресурс", @@ -2150,6 +2151,7 @@ "ACTIVATE": "Активиране на проекта", "DELETE": "Изтриване на проекта", "ORGNAME": "Наименование на организацията", + "ORGID": "Идентификатор на организацията", "ORGDOMAIN": "Домейн на организацията", "STATE": "Статус", "TYPE": "Тип", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 390c5dcdbd..bf977aab43 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -693,6 +693,7 @@ "EMAIL": "Email", "USERNAME": "Uživatelské jméno", "ORGNAME": "Název organizace", + "ORGID": "ID organizace", "PRIMARYDOMAIN": "Primární doména", "PROJECTNAME": "Název projektu", "RESOURCEOWNER": "Vlastník zdroje", @@ -2151,6 +2152,7 @@ "ACTIVATE": "Aktivovat projekt", "DELETE": "Smazat projekt", "ORGNAME": "Název organizace", + "ORGID": "ID organizace", "ORGDOMAIN": "Doména organizace", "STATE": "Stav", "TYPE": "Typ", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index e73c883bd2..12a2f56792 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -693,6 +693,7 @@ "EMAIL": "Email", "USERNAME": "Nutzername", "ORGNAME": "Organisationsname", + "ORGID": "Organisations ID", "PRIMARYDOMAIN": "Primäre Domäne", "PROJECTNAME": "Projektname", "RESOURCEOWNER": "Ressourcenbesitzer", @@ -2150,6 +2151,7 @@ "ACTIVATE": "Projekt aktivieren", "DELETE": "Projekt löschen", "ORGNAME": "Name der Organisation", + "ORGID": "Organisations ID", "ORGDOMAIN": "Domain der Organisation", "STATE": "Status", "TYPE": "Typ", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 5e2cc3f4c9..571a2bd577 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -688,12 +688,13 @@ }, "FILTER": { "TITLE": "Filter", - "STATE": "Status", + "ORGNAME": "Organization Name", + "ORGID": "Organization ID", + "STATE": "State", + "PRIMARYDOMAIN": "Primary Domain", "DISPLAYNAME": "User Display Name", "EMAIL": "Email", "USERNAME": "User Name", - "ORGNAME": "Organization Name", - "PRIMARYDOMAIN": "Primary Domain", "PROJECTNAME": "Project Name", "RESOURCEOWNER": "Resource Owner", "METHODS": { @@ -2153,6 +2154,7 @@ "ACTIVATE": "Activate Project", "DELETE": "Delete Project", "ORGNAME": "Organization Name", + "ORGID": "Organization ID", "ORGDOMAIN": "Organization Domain", "STATE": "Status", "TYPE": "Type", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 198bb3ca8b..19bf21238f 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -693,6 +693,7 @@ "EMAIL": "Email", "USERNAME": "Nombre de usuario", "ORGNAME": "Nombre de organización", + "ORGID": "ID de organización", "PRIMARYDOMAIN": "Dominio primario", "PROJECTNAME": "Nombre de proyecto", "RESOURCEOWNER": "Propietario del recurso", @@ -2151,6 +2152,7 @@ "ACTIVATE": "Activar proyecto", "DELETE": "Borrar proyecto", "ORGNAME": "Nombre de organización", + "ORGID": "ID de organización", "ORGDOMAIN": "Dominio de organización", "STATE": "Estado", "TYPE": "Tipo", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 0d66c4193e..1203a09262 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -693,6 +693,7 @@ "EMAIL": "Courriel", "USERNAME": "Nom de l'utilisateur", "ORGNAME": "Nom de l'organisation", + "ORGID": "ID de l'organisation", "PRIMARYDOMAIN": "Domaine principal", "PROJECTNAME": "Nom du projet", "RESOURCEOWNER": "Propriétaire des ressources", @@ -2150,6 +2151,7 @@ "ACTIVATE": "Activer le projet", "DELETE": "Supprimer le projet", "ORGNAME": "Nom de l'organisation", + "ORGID": "ID de l'organisation", "ORGDOMAIN": "Domaine de l'organisation", "STATE": "Statut", "TYPE": "Type", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 96d1fe16df..eaf8b5f503 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -693,6 +693,7 @@ "EMAIL": "E-mail", "USERNAME": "Felhasználói Név", "ORGNAME": "Szervezet Neve", + "ORGID": "Szervezet ID", "PRIMARYDOMAIN": "Elsődleges Domain", "PROJECTNAME": "Projekt Neve", "RESOURCEOWNER": "Erőforrás Tulajdonos", @@ -2148,6 +2149,7 @@ "ACTIVATE": "Projekt aktiválása", "DELETE": "Projekt törlése", "ORGNAME": "Szervezet neve", + "ORGID": "Szervezet ID", "ORGDOMAIN": "Szervezet domainje", "STATE": "Státusz", "TYPE": "Típus", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index ca788a9467..e6c8e8ca3d 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -652,6 +652,7 @@ "EMAIL": "E-mail", "USERNAME": "Nama belakang", "ORGNAME": "Nama Organisasi", + "ORGID": "ID Organisasi", "PRIMARYDOMAIN": "Domain Utama", "PROJECTNAME": "Nama Proyek", "RESOURCEOWNER": "Pemilik Sumber Daya", @@ -1980,6 +1981,7 @@ "ACTIVATE": "Aktifkan Proyek", "DELETE": "Hapus Proyek", "ORGNAME": "Nama Organisasi", + "ORGID": "ID Organisasi", "ORGDOMAIN": "Domain Organisasi", "STATE": "Status", "TYPE": "Jenis", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 60266bdac5..3609c2b8f2 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -692,6 +692,7 @@ "EMAIL": "Email", "USERNAME": "User Name", "ORGNAME": "Nome organizzazione", + "ORGID": "ID organizzazione", "PRIMARYDOMAIN": "Dominio primario", "PROJECTNAME": "Nome del progetto", "RESOURCEOWNER": "Resource Owner", @@ -2150,6 +2151,7 @@ "ACTIVATE": "Attiva progetto", "DELETE": "Rimuovi progetto", "ORGNAME": "Nome dell'organizzazione", + "ORGID": "ID organizzazione", "ORGDOMAIN": "Dominio", "STATE": "Stato", "TYPE": "Tipo", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 288d491ce7..2d91632ad1 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -693,6 +693,7 @@ "EMAIL": "Eメール", "USERNAME": "ユーザー名", "ORGNAME": "組織名", + "ORGID": "組織ID", "PRIMARYDOMAIN": "プライマリドメイン", "PROJECTNAME": "プロジェクト名", "RESOURCEOWNER": "リソース所有者", @@ -2150,6 +2151,7 @@ "ACTIVATE": "プロジェクトのアクティブ化", "DELETE": "プロジェクトの削除", "ORGNAME": "組織名", + "ORGID": "組織ID", "ORGDOMAIN": "組織ドメイン", "STATE": "ステータス", "TYPE": "タイプ", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index 437c43a3a1..8137ed98df 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -693,6 +693,7 @@ "EMAIL": "이메일", "USERNAME": "사용자 이름", "ORGNAME": "조직 이름", + "ORGID": "조직 ID", "PRIMARYDOMAIN": "기본 도메인", "PROJECTNAME": "프로젝트 이름", "RESOURCEOWNER": "리소스 소유자", @@ -2150,6 +2151,7 @@ "ACTIVATE": "프로젝트 활성화", "DELETE": "프로젝트 삭제", "ORGNAME": "조직 이름", + "ORGID": "조직 ID", "ORGDOMAIN": "조직 도메인", "STATE": "상태", "TYPE": "유형", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 2e62723939..f11bcc5d63 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -693,6 +693,7 @@ "EMAIL": "Е-пошта", "USERNAME": "Корисничко име", "ORGNAME": "Име на организацијата", + "ORGID": "Идентификатор на организацијата", "PRIMARYDOMAIN": "Примарен домен", "PROJECTNAME": "Име на проектот", "RESOURCEOWNER": "Сопственик на ресурсот", @@ -2153,6 +2154,7 @@ "ACTIVATE": "Активирај проект", "DELETE": "Избриши проект", "ORGNAME": "Име на организација", + "ORGID": "Идентификатор на организација", "ORGDOMAIN": "Домен на организација", "STATE": "Статус", "TYPE": "Тип", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 7e549f64ba..54fb6dfb26 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -693,6 +693,7 @@ "EMAIL": "E-mail", "USERNAME": "Gebruikersnaam", "ORGNAME": "Organisatienaam", + "ORGID": "Organisatie ID", "PRIMARYDOMAIN": "Primair domein", "PROJECTNAME": "Projectnaam", "RESOURCEOWNER": "Eigenaar van de bron", @@ -2150,6 +2151,7 @@ "ACTIVATE": "Activeer Project", "DELETE": "Verwijder Project", "ORGNAME": "Organisatienaam", + "ORGID": "Organisatie ID", "ORGDOMAIN": "Organisatie Domein", "STATE": "Status", "TYPE": "Type", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 2f18c343f7..ae23c79427 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -692,6 +692,7 @@ "EMAIL": "Email", "USERNAME": "Nazwa Użytkownika", "ORGNAME": "Nazwa Organizacji", + "ORGID": "ID Organizacji", "PRIMARYDOMAIN": "Domena podstawowa", "PROJECTNAME": "Nazwa Projektu", "RESOURCEOWNER": "Właściciel Zasobu", @@ -2149,6 +2150,7 @@ "ACTIVATE": "Aktywuj projekt", "DELETE": "Usuń projekt", "ORGNAME": "Nazwa organizacji", + "ORGID": "ID organizacji", "ORGDOMAIN": "Domena organizacji", "STATE": "Status", "TYPE": "Typ", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 08181f6ead..71abb8d51f 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -693,6 +693,7 @@ "EMAIL": "E-mail", "USERNAME": "Nome de Usuário", "ORGNAME": "Nome da Organização", + "ORGID": "ID da Organização", "PRIMARYDOMAIN": "Domínio primário", "PROJECTNAME": "Nome do Projeto", "RESOURCEOWNER": "Proprietário do Recurso", @@ -2152,6 +2153,7 @@ "ACTIVATE": "Ativar Projeto", "DELETE": "Excluir Projeto", "ORGNAME": "Nome da Organização", + "ORGID": "ID da Organização", "ORGDOMAIN": "Domínio da Organização", "STATE": "Status", "TYPE": "Tipo", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index b07897f316..f6a183bede 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -691,6 +691,7 @@ "EMAIL": "E-mail", "USERNAME": "Numele utilizatorului", "ORGNAME": "Numele organizației", + "ORGID": "ID-ul organizației", "PRIMARYDOMAIN": "Domeniu principal", "PROJECTNAME": "Numele proiectului", "RESOURCEOWNER": "Proprietarul resursei", @@ -2148,6 +2149,7 @@ "ACTIVATE": "Activați proiectul", "DELETE": "Ștergeți proiectul", "ORGNAME": "Nume organizație", + "ORGID": "ID organizație", "ORGDOMAIN": "Domeniu organizație", "STATE": "Stare", "TYPE": "Tip", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index c6ef31499e..c4955f83fd 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -693,6 +693,7 @@ "EMAIL": "Электронная почта", "USERNAME": "Имя пользователя", "ORGNAME": "Название организации", + "ORGID": "ID организации", "PRIMARYDOMAIN": "Основной домен", "PROJECTNAME": "Название проекта", "RESOURCEOWNER": "Владелец ресурса", @@ -2237,6 +2238,7 @@ "ACTIVATE": "Активировать проект", "DELETE": "Удалить проект", "ORGNAME": "Название организации", + "ORGID": "ID организации", "ORGDOMAIN": "Домен организации", "STATE": "Статус", "TYPE": "Тип", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index c356e635e0..1144fd4585 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -693,6 +693,7 @@ "EMAIL": "E-post", "USERNAME": "Användarnamn", "ORGNAME": "Organisationsnamn", + "ORGID": "Organisations ID", "PRIMARYDOMAIN": "Primär domän", "PROJECTNAME": "Projektnamn", "RESOURCEOWNER": "Resursägare", @@ -2154,6 +2155,7 @@ "ACTIVATE": "Aktivera projekt", "DELETE": "Ta bort projekt", "ORGNAME": "Organisationsnamn", + "ORGID": "Organisations ID", "ORGDOMAIN": "Organisationsdomän", "STATE": "Status", "TYPE": "Typ", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 8be3316b0b..943a9aef16 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -693,6 +693,7 @@ "EMAIL": "邮箱", "USERNAME": "用户名", "ORGNAME": "组织名称", + "ORGID": "组织ID", "PRIMARYDOMAIN": "主域", "PROJECTNAME": "项目名称", "RESOURCEOWNER": "资源所有者", @@ -2149,6 +2150,7 @@ "ACTIVATE": "启用项目", "DELETE": "删除项目", "ORGNAME": "组织名称", + "ORGID": "组织ID", "ORGDOMAIN": "组织域名", "STATE": "状态", "TYPE": "类型", From d79d5e7b964e3f1592489e956156f8f1c87e4710 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Mon, 12 May 2025 12:05:12 +0200 Subject: [PATCH 049/123] fix(projection): remove users with factors (#9877) # Which Problems Are Solved When users are removed, their auth factors stay in the projection. This data inconsistency is visible if a removed user is recreated with the same ID. In such a case, the login UI and the query API methods show the removed users auth methods. This is unexpected behavior. The old users auth methods are not usable to log in and they are not found by the command side. This is expected behavior. # How the Problems Are Solved The auth factors projection reduces the user removed event by deleting all factors. # Additional Context - Reported by support request - requires backport to 2.x and 3.x --- internal/query/projection/user_auth_method.go | 19 +++++++++++++ .../query/projection/user_auth_method_test.go | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/internal/query/projection/user_auth_method.go b/internal/query/projection/user_auth_method.go index b986df1558..7726550ffd 100644 --- a/internal/query/projection/user_auth_method.go +++ b/internal/query/projection/user_auth_method.go @@ -125,6 +125,10 @@ func (p *userAuthMethodProjection) Reducers() []handler.AggregateReducer { Event: user.HumanOTPEmailRemovedType, Reduce: p.reduceRemoveAuthMethod, }, + { + Event: user.UserRemovedType, + Reduce: p.reduceUserRemoved, + }, }, }, { @@ -311,3 +315,18 @@ func (p *userAuthMethodProjection) reduceOwnerRemoved(event eventstore.Event) (* }, ), nil } + +func (p *userAuthMethodProjection) reduceUserRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*user.UserRemovedEvent) + if !ok { + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-FwDZ8", "reduce.wrong.event.type %s", user.UserRemovedType) + } + return handler.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(UserAuthMethodInstanceIDCol, e.Aggregate().InstanceID), + handler.NewCond(UserAuthMethodResourceOwnerCol, e.Aggregate().ResourceOwner), + handler.NewCond(UserAuthMethodUserIDCol, e.Aggregate().ID), + }, + ), nil +} diff --git a/internal/query/projection/user_auth_method_test.go b/internal/query/projection/user_auth_method_test.go index fb3d6d9d91..e21a480a9d 100644 --- a/internal/query/projection/user_auth_method_test.go +++ b/internal/query/projection/user_auth_method_test.go @@ -528,6 +528,34 @@ func TestUserAuthMethodProjection_reduces(t *testing.T) { }, }, }, + { + name: "reduceUserRemoved", + reduce: (&userAuthMethodProjection{}).reduceUserRemoved, + args: args{ + event: getEvent( + testEvent( + user.UserRemovedType, + user.AggregateType, + nil, + ), user.UserRemovedEventMapper), + }, + want: wantReduce{ + aggregateType: eventstore.AggregateType("user"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM projections.user_auth_methods5 WHERE (instance_id = $1) AND (resource_owner = $2) AND (user_id = $3)", + expectedArgs: []interface{}{ + "instance-id", + "ro-id", + "agg-id", + }, + }, + }, + }, + }, + }, { name: "org reduceOwnerRemoved", reduce: (&userAuthMethodProjection{}).reduceOwnerRemoved, From 1383cb070264cba0a5ccc5c14762a24ef24a9644 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 13 May 2025 10:32:48 +0200 Subject: [PATCH 050/123] fix: correctly "or"-join ldap userfilters (#9855) # Which Problems Are Solved LDAP userfilters are joined, but as it not handled as a list of filters but as a string they are not or-joined. # How the Problems Are Solved Separate userfilters as list of filters and join them correctly with "or" condition. # Additional Changes None # Additional Context Closes #7003 --------- Co-authored-by: Marco A. --- internal/idp/providers/ldap/session.go | 21 ++++++++++++--------- internal/idp/providers/ldap/session_test.go | 10 +++++----- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/internal/idp/providers/ldap/session.go b/internal/idp/providers/ldap/session.go index 1679e35b61..a78dd02d73 100644 --- a/internal/idp/providers/ldap/session.go +++ b/internal/idp/providers/ldap/session.go @@ -133,7 +133,6 @@ func tryBind( username, password, timeout, - rootCA, ) } @@ -189,12 +188,11 @@ func trySearchAndUserBind( username string, password string, timeout time.Duration, - rootCA []byte, ) (*ldap.Entry, error) { searchQuery := queriesAndToSearchQuery( objectClassesToSearchQuery(objectClasses), queriesOrToSearchQuery( - userFiltersToSearchQuery(userFilters, username), + userFiltersToSearchQuery(userFilters, username)..., ), ) @@ -218,7 +216,12 @@ func trySearchAndUserBind( user := sr.Entries[0] // Bind as the user to verify their password - if err = conn.Bind(user.DN, password); err != nil { + userDN, err := ldap.ParseDN(user.DN) + if err != nil { + logging.WithFields("userDN", user.DN).WithError(err).Info("ldap user parse DN failed") + return nil, err + } + if err = conn.Bind(userDN.String(), password); err != nil { logging.WithFields("userDN", user.DN).WithError(err).Info("ldap user bind failed") return nil, ErrFailedLogin } @@ -261,12 +264,12 @@ func objectClassesToSearchQuery(classes []string) string { return searchQuery } -func userFiltersToSearchQuery(filters []string, username string) string { - searchQuery := "" - for _, filter := range filters { - searchQuery += "(" + filter + "=" + ldap.EscapeFilter(username) + ")" +func userFiltersToSearchQuery(filters []string, username string) []string { + searchQueries := make([]string, len(filters)) + for i, filter := range filters { + searchQueries[i] = "(" + filter + "=" + username + ")" } - return searchQuery + return searchQueries } func mapLDAPEntryToUser( diff --git a/internal/idp/providers/ldap/session_test.go b/internal/idp/providers/ldap/session_test.go index 69ba3a3256..89fee68718 100644 --- a/internal/idp/providers/ldap/session_test.go +++ b/internal/idp/providers/ldap/session_test.go @@ -49,31 +49,31 @@ func TestProvider_userFiltersToSearchQuery(t *testing.T) { name string fields []string username string - want string + want []string }{ { name: "zero", fields: []string{}, username: "user", - want: "", + want: []string{}, }, { name: "one", fields: []string{"test"}, username: "user", - want: "(test=user)", + want: []string{"(test=user)"}, }, { name: "three", fields: []string{"test1", "test2", "test3"}, username: "user", - want: "(test1=user)(test2=user)(test3=user)", + want: []string{"(test1=user)", "(test2=user)", "(test3=user)"}, }, { name: "five", fields: []string{"test1", "test2", "test3", "test4", "test5"}, username: "user", - want: "(test1=user)(test2=user)(test3=user)(test4=user)(test5=user)", + want: []string{"(test1=user)", "(test2=user)", "(test3=user)", "(test4=user)", "(test5=user)"}, }, } for _, tt := range tests { From 4480cfcf56825b96afe3650ad6ba1976f9f5212b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 16 May 2025 10:41:35 +0200 Subject: [PATCH 051/123] docs(advisory): position precision fix (#9882) # Which Problems Are Solved We are deploying precision fixes on the `position` values of the eventstore. The fix itself might break systems that were already affected by the bug. # How the Problems Are Solved Add a technical advisory that explains background and steps to fix the Zitadel database when affected. # Additional Context - Original issue: [8671](https://github.com/zitadel/zitadel/issues/8671) - Follow-up issue: [8863](https://github.com/zitadel/zitadel/issues/8863) - Re-fix: https://github.com/zitadel/zitadel/pull/9881 --- docs/docs/support/advisory/a10016.md | 98 ++++++++++++++++++++++++ docs/docs/support/technical_advisory.mdx | 12 +++ 2 files changed, 110 insertions(+) create mode 100644 docs/docs/support/advisory/a10016.md diff --git a/docs/docs/support/advisory/a10016.md b/docs/docs/support/advisory/a10016.md new file mode 100644 index 0000000000..794c354e42 --- /dev/null +++ b/docs/docs/support/advisory/a10016.md @@ -0,0 +1,98 @@ +--- +title: Technical Advisory 10016 +--- + +## Date + +Versions:[^1] + +- v2.65.x: > v2.65.9 + +Date: 2025-05-14 + +Last updated: 2025-05-14 + +[^1]: The mentioned fix is being rolled out gradually on multiple patch releases of Zitadel. This advisory will be updated as we release these versions. + +## Description + +### Background + +Zitadel uses a eventstore table as main source of truth for state changes. +Projections are tables which provide alternative views of state, which are built using events. +In order to know which events are reduced into projections, we use a `position` column in the eventstore and a dedicated table which records the current state. + +### Problem + +Zitadel prior to the listed version had a precision bug. The `position` column uses a fixed-point numeric type. In Zitadel's Go code we used a `float64`. In certain cases we noticed a precision loss when Zitadel updated the `current_states` table. + +## Impact + +During a past attempt to fix this, we got reports of failing projections inside Zitadel. Because the precision became exact certain compare operations like *equal*, *less then*, etc would now return different results. This was because the values in `current_states` would already have lost precision from a broken version. This might happen to **some** deployments or projections: there is only a small probability. + +We are releasing the fix again and your system might get affected. + +- Original issue: [8671](https://github.com/zitadel/zitadel/issues/8671) +- Follow-up issue: [8863](https://github.com/zitadel/zitadel/issues/8863) + +## Mitigation + +When **after** deploying a fixed version and only when experiencing problems described by issue [8863](https://github.com/zitadel/zitadel/issues/8863), the following queries can be executed to fix `current_state` rows which have "broken" values. We recommend doing this in a transaction in order to double-check the affected rows, before committing the update. + +```sql +begin; + +with + broken as ( + select + s.projection_name, + s.instance_id, + s.aggregate_id, + s.aggregate_type, + s.sequence, + s."position" as old_position, + e."position" as new_position + from + projections.current_states s + join eventstore.events2 e on s.instance_id = e.instance_id + and s.aggregate_id = e.aggregate_id + and s.aggregate_type = e.aggregate_type + and s.sequence = e.sequence + and s."position" != e."position" + where + s."position" != 0 + and projection_name != 'projections.execution_handler' + ),fixed as ( + update projections.current_states s + set + "position" = b.new_position + from + broken b + where + s.instance_id = b.instance_id + and s.projection_name = b.projection_name + and s.aggregate_id = b.aggregate_id + and s.aggregate_type = b.aggregate_type + and s.sequence = b.sequence + ) +select + b.projection_name, + b.instance_id, + b.aggregate_id, + b.aggregate_type, + b.sequence, + b.old_position, + b.new_position +from + broken b; +``` + +If the output from the above looks reasonable, for example not a huge difference between `old_position` and `new_position`, commit the transaction: + +```sql +commit; +``` + +When there are no rows returned, your system was not affected by precision loss. + +When there's unexpected output, use `rollback;` instead. diff --git a/docs/docs/support/technical_advisory.mdx b/docs/docs/support/technical_advisory.mdx index 0d8818c32c..5d13cfe3e5 100644 --- a/docs/docs/support/technical_advisory.mdx +++ b/docs/docs/support/technical_advisory.mdx @@ -238,6 +238,18 @@ We understand that these advisories may include breaking changes, and we aim to 3.0.0 2025-03-31 + + + A-10016 + + Position precision fix + Manual Intervention + + + + 2.65.10 + 2025-05-14 + ## Subscribe to our Mailing List From fefe9d27a0a5c32592df9967f07729c113351cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Fri, 16 May 2025 12:07:02 +0200 Subject: [PATCH 052/123] docs: fix typo in email notification provider description (#9890) --- docs/docs/guides/manage/customize/notification-providers.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/manage/customize/notification-providers.mdx b/docs/docs/guides/manage/customize/notification-providers.mdx index b94ce7542d..3bd1b358e5 100644 --- a/docs/docs/guides/manage/customize/notification-providers.mdx +++ b/docs/docs/guides/manage/customize/notification-providers.mdx @@ -74,7 +74,7 @@ A provider with HTTP type will send the messages and the data to a pre-defined w - First [add a new Email Provider of type HTTP](/apis/resources/admin/admin-service-add-email-provider-http) to create a new HTTP provider that can be used to send SMS messages: + First [add a new Email Provider of type HTTP](/apis/resources/admin/admin-service-add-email-provider-http) to create a new HTTP provider that can be used to send Email messages: ```bash curl -L 'https://$CUSTOM-DOMAIN/admin/v1/email/http' \ From 38013d0e84862dfe379c3ee9b54dfcde35237b9e Mon Sep 17 00:00:00 2001 From: Juriaan Kennedy <97170019+juriaankennedy@users.noreply.github.com> Date: Fri, 16 May 2025 17:53:45 +0200 Subject: [PATCH 053/123] feat(crypto): support for SHA2 and PHPass password hashes (#9809) # Which Problems Are Solved - Allow users to use SHA-256 and SHA-512 hashing algorithms. These algorithms are used by Linux's crypt(3) function. - Allow users to import passwords using the PHPass algorithm. This algorithm is used by older PHP systems, WordPress in particular. # How the Problems Are Solved - Upgrade passwap to [v0.9.0](https://github.com/zitadel/passwap/releases/tag/v0.9.0) - Add sha2 and phpass as a new verifier option in defaults.yaml # Additional Changes - Updated docs to explain the two algorithms # Additional Context Implements the changes in the passwap library from https://github.com/zitadel/passwap/pull/59 and https://github.com/zitadel/passwap/pull/60 --- cmd/defaults.yaml | 11 +- docs/docs/concepts/architecture/secrets.md | 2 + go.mod | 10 +- go.sum | 20 +-- internal/crypto/passwap.go | 56 +++++++- internal/crypto/passwap_test.go | 155 +++++++++++++++++++++ 6 files changed, 233 insertions(+), 21 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index c13d5337b1..397e9af376 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -653,7 +653,7 @@ SystemDefaults: # or cost are automatically re-hashed using this config, # upon password validation or update. Hasher: - # Supported algorithms: "argon2i", "argon2id", "bcrypt", "scrypt", "pbkdf2" + # Supported algorithms: "argon2i", "argon2id", "bcrypt", "scrypt", "pbkdf2", "sha2" # Depending on the algorithm, different configuration options take effect. Algorithm: bcrypt # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ALGORITHM # Cost takes effect for the algorithms bcrypt and scrypt @@ -664,10 +664,11 @@ SystemDefaults: Memory: 32768 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_MEMORY # Threads takes effect for the algorithms argon2i and argon2id Threads: 4 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_THREADS - # Rounds takes effect for the algorithm pbkdf2 + # Rounds takes effect for the algorithm pbkdf2 and sha2 Rounds: 290000 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_ROUNDS - # Hash takes effect for the algorithm pbkdf2 - # Can be "sha1", "sha224", "sha256", "sha384" or "sha512" + # Hash takes effect for the algorithm pbkdf2 and sha2 + # Can be "sha1", "sha224", "sha256", "sha384" or "sha512" for pbkdf2 + # Can be "sha256" or "sha512" for sha2 Hash: sha256 # ZITADEL_SYSTEMDEFAULTS_PASSWORDHASHER_HASHER_HASH # Verifiers enable the possibility of verifying @@ -689,6 +690,8 @@ SystemDefaults: # - "md5" # md5Crypt with salt and password shuffling. # - "md5plain" # md5 digest of a password without salt # - "md5salted" # md5 digest of a salted password + # - "phpass" + # - "sha2" # crypt(3) SHA-256 and SHA-512 # - "scrypt" # - "pbkdf2" # verifier for all pbkdf2 hash modes. SecretHasher: diff --git a/docs/docs/concepts/architecture/secrets.md b/docs/docs/concepts/architecture/secrets.md index f8f195114b..1f36802754 100644 --- a/docs/docs/concepts/architecture/secrets.md +++ b/docs/docs/concepts/architecture/secrets.md @@ -71,6 +71,8 @@ The following hash algorithms are supported: - md5: implementation of md5Crypt with salt and password shuffling [^2] - md5plain: md5 digest of a password without salt [^2] - md5salted: md5 digest of a salted password [^2] +- phpass: md5 digest with PHPass algorithm (used in WordPress) [^2] +- sha2: implementation of crypt(3) SHA-256 & SHA-512 - scrypt - pbkdf2 diff --git a/go.mod b/go.mod index 99df9ad86f..00f65c1331 100644 --- a/go.mod +++ b/go.mod @@ -73,7 +73,7 @@ require ( github.com/zitadel/exifremove v0.1.0 github.com/zitadel/logging v0.6.2 github.com/zitadel/oidc/v3 v3.36.1 - github.com/zitadel/passwap v0.7.0 + github.com/zitadel/passwap v0.9.0 github.com/zitadel/saml v0.3.5 github.com/zitadel/schema v1.3.1 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 @@ -87,12 +87,12 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.35.0 go.opentelemetry.io/otel/trace v1.35.0 go.uber.org/mock v0.5.0 - golang.org/x/crypto v0.36.0 + golang.org/x/crypto v0.37.0 golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 golang.org/x/net v0.37.0 golang.org/x/oauth2 v0.28.0 - golang.org/x/sync v0.12.0 - golang.org/x/text v0.23.0 + golang.org/x/sync v0.13.0 + golang.org/x/text v0.24.0 google.golang.org/api v0.227.0 google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4 google.golang.org/grpc v1.71.0 @@ -226,7 +226,7 @@ require ( github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect - golang.org/x/sys v0.31.0 + golang.org/x/sys v0.32.0 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect nhooyr.io/websocket v1.8.11 // indirect diff --git a/go.sum b/go.sum index cb8f0cf4bd..f361fb87fa 100644 --- a/go.sum +++ b/go.sum @@ -807,8 +807,8 @@ github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= github.com/zitadel/oidc/v3 v3.36.1 h1:1AT1NqKKEqAwx4GmKJZ9fYkWH2WIn/VKMfQ46nBtRf0= github.com/zitadel/oidc/v3 v3.36.1/go.mod h1:dApGZLvWZTHRuxmcbQlW5d2XVjVYR3vGOdq536igmTs= -github.com/zitadel/passwap v0.7.0 h1:TQTr9TV75PLATGICor1g5hZDRNHRvB9t0Hn4XkiR7xQ= -github.com/zitadel/passwap v0.7.0/go.mod h1:/NakQNYahdU+YFEitVD6mlm8BLfkiIT+IM5wgClRoAY= +github.com/zitadel/passwap v0.9.0 h1:QvDK8OHKdb73C0m+mwXvu87UJSBqix3oFwTVENHdv80= +github.com/zitadel/passwap v0.9.0/go.mod h1:6QzwFjDkIr3FfudzSogTOx5Ydhq4046dRJtDM/kX+G8= github.com/zitadel/saml v0.3.5 h1:L1RKWS5y66cGepVxUGjx/WSBOtrtSpRA/J3nn5BJLOY= github.com/zitadel/saml v0.3.5/go.mod h1:ybs3e4tIWdYgSYBpuCsvf3T4FNDfbXYM+GPv5vIpHYk= github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= @@ -881,8 +881,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= -golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= @@ -970,8 +970,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1016,8 +1016,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1038,8 +1038,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= -golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= diff --git a/internal/crypto/passwap.go b/internal/crypto/passwap.go index e14c2dfaaf..e4f3313342 100644 --- a/internal/crypto/passwap.go +++ b/internal/crypto/passwap.go @@ -14,7 +14,9 @@ import ( "github.com/zitadel/passwap/md5plain" "github.com/zitadel/passwap/md5salted" "github.com/zitadel/passwap/pbkdf2" + "github.com/zitadel/passwap/phpass" "github.com/zitadel/passwap/scrypt" + "github.com/zitadel/passwap/sha2" "github.com/zitadel/passwap/verifier" "github.com/zitadel/zitadel/internal/zerrors" @@ -51,6 +53,8 @@ const ( HashNameMd5 HashName = "md5" // verify only, as hashing with md5 is insecure and deprecated HashNameMd5Plain HashName = "md5plain" // verify only, as hashing with md5 is insecure and deprecated HashNameMd5Salted HashName = "md5salted" // verify only, as hashing with md5 is insecure and deprecated + HashNamePHPass HashName = "phpass" // verify only, as hashing with md5 is insecure and deprecated + HashNameSha2 HashName = "sha2" // hash and verify HashNameScrypt HashName = "scrypt" // hash and verify HashNamePBKDF2 HashName = "pbkdf2" // hash and verify ) @@ -125,6 +129,14 @@ var knowVerifiers = map[HashName]prefixVerifier{ prefixes: []string{md5salted.Prefix}, verifier: md5salted.Verifier, }, + HashNameSha2: { + prefixes: []string{sha2.Sha256Identifier, sha2.Sha512Identifier}, + verifier: sha2.Verifier, + }, + HashNamePHPass: { + prefixes: []string{phpass.IdentifierP, phpass.IdentifierH}, + verifier: phpass.Verifier, + }, } func (c *HashConfig) buildVerifiers() (verifiers []verifier.Verifier, prefixes []string, err error) { @@ -158,9 +170,11 @@ func (c *HasherConfig) buildHasher() (hasher passwap.Hasher, prefixes []string, return c.scrypt() case HashNamePBKDF2: return c.pbkdf2() + case HashNameSha2: + return c.sha2() case "": return nil, nil, fmt.Errorf("missing hasher algorithm") - case HashNameArgon2, HashNameMd5: + case HashNameArgon2, HashNameMd5, HashNameMd5Plain, HashNameMd5Salted, HashNamePHPass: fallthrough default: return nil, nil, fmt.Errorf("invalid algorithm %q", c.Algorithm) @@ -296,6 +310,44 @@ func (c *HasherConfig) pbkdf2() (passwap.Hasher, []string, error) { case HashModeSHA512: return pbkdf2.NewSHA512(p), prefix, nil default: - return nil, nil, fmt.Errorf("unsuppored pbkdf2 hash mode: %s", hash) + return nil, nil, fmt.Errorf("unsupported pbkdf2 hash mode: %s", hash) + } +} + +func (c *HasherConfig) sha2Params() (use512 bool, rounds int, err error) { + var dst = struct { + Rounds uint32 `mapstructure:"Rounds"` + Hash HashMode `mapstructure:"Hash"` + }{} + if err := c.decodeParams(&dst); err != nil { + return false, 0, fmt.Errorf("decode sha2 params: %w", err) + } + switch dst.Hash { + case HashModeSHA256: + use512 = false + case HashModeSHA512: + use512 = true + case HashModeSHA1, HashModeSHA224, HashModeSHA384: + fallthrough + default: + return false, 0, fmt.Errorf("cannot use %s with sha2", dst.Hash) + } + if dst.Rounds > sha2.RoundsMax { + return false, 0, fmt.Errorf("rounds with sha2 cannot be larger than %d", sha2.RoundsMax) + } else { + rounds = int(dst.Rounds) + } + return use512, rounds, nil +} + +func (c *HasherConfig) sha2() (passwap.Hasher, []string, error) { + use512, rounds, err := c.sha2Params() + if err != nil { + return nil, nil, err + } + if use512 { + return sha2.New512(rounds), []string{sha2.Sha256Identifier, sha2.Sha512Identifier}, nil + } else { + return sha2.New256(rounds), []string{sha2.Sha256Identifier, sha2.Sha512Identifier}, nil } } diff --git a/internal/crypto/passwap_test.go b/internal/crypto/passwap_test.go index b872b0e298..dfcafd1406 100644 --- a/internal/crypto/passwap_test.go +++ b/internal/crypto/passwap_test.go @@ -14,6 +14,7 @@ import ( "github.com/zitadel/passwap/md5salted" "github.com/zitadel/passwap/pbkdf2" "github.com/zitadel/passwap/scrypt" + "github.com/zitadel/passwap/sha2" ) func TestPasswordHasher_EncodingSupported(t *testing.T) { @@ -78,7 +79,9 @@ func TestPasswordHashConfig_PasswordHasher(t *testing.T) { HashNameBcrypt, HashNameMd5, HashNameMd5Salted, + HashNamePHPass, HashNameScrypt, + HashNameSha2, "foobar", }, Hasher: HasherConfig{ @@ -142,6 +145,15 @@ func TestPasswordHashConfig_PasswordHasher(t *testing.T) { }, wantErr: true, }, + { + name: "invalid phpass", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNamePHPass, + }, + }, + wantErr: true, + }, { name: "invalid argon2", fields: fields{ @@ -357,6 +369,59 @@ func TestPasswordHashConfig_PasswordHasher(t *testing.T) { }, wantPrefixes: []string{pbkdf2.Prefix, argon2.Prefix, bcrypt.Prefix, md5.Prefix, md5salted.Prefix}, }, + { + name: "sha2, parse error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameSha2, + Params: map[string]any{ + "cost": "bar", + }, + }, + }, + wantErr: true, + }, + { + name: "pbkdf2, hash mode error", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameSha2, + Params: map[string]any{ + "Rounds": 12, + "Hash": "foo", + }, + }, + }, + wantErr: true, + }, + { + name: "sha2, sha256", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameSha2, + Params: map[string]any{ + "Rounds": 12, + "Hash": HashModeSHA256, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5, HashNameMd5Plain}, + }, + wantPrefixes: []string{sha2.Sha256Identifier, sha2.Sha512Identifier, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, + { + name: "sha2, sha512", + fields: fields{ + Hasher: HasherConfig{ + Algorithm: HashNameSha2, + Params: map[string]any{ + "Rounds": 12, + "Hash": HashModeSHA512, + }, + }, + Verifiers: []HashName{HashNameArgon2, HashNameBcrypt, HashNameMd5, HashNameMd5Plain}, + }, + wantPrefixes: []string{sha2.Sha256Identifier, sha2.Sha512Identifier, argon2.Prefix, bcrypt.Prefix, md5.Prefix}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -723,3 +788,93 @@ func TestHasherConfig_pbkdf2Params(t *testing.T) { }) } } + +func TestHasherConfig_sha2Params(t *testing.T) { + type fields struct { + Params map[string]any + } + tests := []struct { + name string + fields fields + want512 bool + wantRounds int + wantErr bool + }{ + { + name: "decode error", + fields: fields{ + Params: map[string]any{ + "foo": "bar", + }, + }, + wantErr: true, + }, + { + name: "sha1", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha1", + }, + }, + wantErr: true, + }, + { + name: "sha224", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha224", + }, + }, + wantErr: true, + }, + { + name: "sha256", + fields: fields{ + Params: map[string]any{ + "Rounds": 5000, + "Hash": "sha256", + }, + }, + want512: false, + wantRounds: 5000, + }, + { + name: "sha384", + fields: fields{ + Params: map[string]any{ + "Rounds": 12, + "Hash": "sha384", + }, + }, + wantErr: true, + }, + { + name: "sha512", + fields: fields{ + Params: map[string]any{ + "Rounds": 15000, + "Hash": "sha512", + }, + }, + want512: true, + wantRounds: 15000, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &HasherConfig{ + Params: tt.fields.Params, + } + got512, gotRounds, err := c.sha2Params() + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want512, got512) + assert.Equal(t, tt.wantRounds, gotRounds) + }) + } +} From 056b01f78d64a57d1f877a0d97f16d7f9295705e Mon Sep 17 00:00:00 2001 From: Daniel Fabian <67621761+FabianDaniel00@users.noreply.github.com> Date: Mon, 19 May 2025 09:11:54 +0200 Subject: [PATCH 054/123] fix: typoe in "Migrate from ZITADEL" documentation (#9867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved - Fixed a typoe in ["Migrate from ZITADEL" documentation](https://zitadel.com/docs/guides/migrate/sources/zitadel#authorization) Co-authored-by: Fabienne Bühler --- docs/docs/guides/migrate/sources/zitadel.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/guides/migrate/sources/zitadel.md b/docs/docs/guides/migrate/sources/zitadel.md index 8a8dd60a3d..99b6e1d64e 100644 --- a/docs/docs/guides/migrate/sources/zitadel.md +++ b/docs/docs/guides/migrate/sources/zitadel.md @@ -40,7 +40,7 @@ You need a PAT from a service user with IAM Owner permissions in both the source 4. Go to the Default settings 5. Add the import_user as [manager](/docs/guides/manage/console/managers) with the role "IAM Owner" -Save the PAT to the environment variabel `PAT_EXPORT_TOKEN` and the source domain as `ZITADEL_EXPORT_DOMAIN` to run the following scripts. +Save the PAT to the environment variable `PAT_EXPORT_TOKEN` and the source domain as `ZITADEL_EXPORT_DOMAIN` to run the following scripts. ### Target system @@ -50,7 +50,7 @@ Save the PAT to the environment variabel `PAT_EXPORT_TOKEN` and the source domai 4. Go to the Default settings 5. Add the export_user as [manager](/docs/guides/manage/console/managers) with the role "IAM Owner" -Save the PAT to the environment variabel `PAT_IMPORT_TOKEN` and the source domain as `ZITADEL_IMPORT_DOMAIN` to run the following scripts. +Save the PAT to the environment variable `PAT_IMPORT_TOKEN` and the source domain as `ZITADEL_IMPORT_DOMAIN` to run the following scripts. :::warning Clean-up You should let the PAT expire as soon as possible. From 1b2fd23e0b6fe21e144df85e449cf45b59bb4ed9 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 19 May 2025 11:25:17 +0200 Subject: [PATCH 055/123] fix: idp user information mapping (#9892) # Which Problems Are Solved When retrieving the information of an IdP intent, depending on the IdP type (e.g. Apple), there was issue when mapping the stored (event) information back to the specific IdP type, potentially leading to a panic. # How the Problems Are Solved - Correctly initialize the user struct to map the information to. # Additional Changes none # Additional Context - reported by a support request - needs backport to 3.x and 2.x --- internal/api/grpc/user/v2/intent.go | 8 ++++---- internal/idp/providers/apple/session.go | 4 ++++ internal/idp/providers/google/google.go | 4 ++++ internal/idp/providers/oidc/session.go | 4 ++++ 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index 8043a9bdae..afb34deb83 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -167,11 +167,11 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R var idpUser idp.User switch p := provider.(type) { case *apple.Provider: - idpUser, err = unmarshalIdpUser(intent.IDPUser, &apple.User{}) + idpUser, err = unmarshalIdpUser(intent.IDPUser, apple.InitUser()) case *oauth.Provider: idpUser, err = unmarshalRawIdpUser(intent.IDPUser, p.User()) case *oidc.Provider: - idpUser, err = unmarshalIdpUser(intent.IDPUser, &oidc.User{UserInfo: &oidc_pkg.UserInfo{}}) + idpUser, err = unmarshalIdpUser(intent.IDPUser, oidc.InitUser()) case *jwt.Provider: idpUser, err = unmarshalIdpUser(intent.IDPUser, &jwt.User{}) case *azuread.Provider: @@ -179,9 +179,9 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R case *github.Provider: idpUser, err = unmarshalIdpUser(intent.IDPUser, &github.User{}) case *gitlab.Provider: - idpUser, err = unmarshalIdpUser(intent.IDPUser, &oidc.User{UserInfo: &oidc_pkg.UserInfo{}}) + idpUser, err = unmarshalIdpUser(intent.IDPUser, oidc.InitUser()) case *google.Provider: - idpUser, err = unmarshalIdpUser(intent.IDPUser, &google.User{User: &oidc.User{UserInfo: &oidc_pkg.UserInfo{}}}) + idpUser, err = unmarshalIdpUser(intent.IDPUser, google.InitUser()) case *saml.Provider: idpUser, err = unmarshalIdpUser(intent.IDPUser, &saml.UserMapper{}) case *ldap.Provider: diff --git a/internal/idp/providers/apple/session.go b/internal/idp/providers/apple/session.go index 9395d84b2b..99794d18a2 100644 --- a/internal/idp/providers/apple/session.go +++ b/internal/idp/providers/apple/session.go @@ -60,6 +60,10 @@ func NewUser(info *openid.UserInfo, names userNamesFormValue) *User { return &User{User: user} } +func InitUser() idp.User { + return &User{User: oidc.InitUser()} +} + // User extends the [oidc.User] by returning the email as preferred_username, since Apple does not return the latter. type User struct { *oidc.User diff --git a/internal/idp/providers/google/google.go b/internal/idp/providers/google/google.go index 221f2b61ae..083d4aef62 100644 --- a/internal/idp/providers/google/google.go +++ b/internal/idp/providers/google/google.go @@ -34,6 +34,10 @@ var userMapper = func(info *openid.UserInfo) idp.User { return &User{oidc.DefaultMapper(info)} } +func InitUser() idp.User { + return &User{oidc.InitUser()} +} + // User is a representation of the authenticated Google and implements the [idp.User] interface // by wrapping an [idp.User] (implemented by [oidc.User]). It overwrites the [GetPreferredUsername] to use the `email` claim. type User struct { diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go index 430a14e5bb..9e1e55baf5 100644 --- a/internal/idp/providers/oidc/session.go +++ b/internal/idp/providers/oidc/session.go @@ -96,6 +96,10 @@ func NewUser(info *oidc.UserInfo) *User { return &User{UserInfo: info} } +func InitUser() *User { + return &User{UserInfo: &oidc.UserInfo{}} +} + type User struct { *oidc.UserInfo } From 968d91a3e0a745fda5b6dfbffdbf1dee8f1306a6 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 19 May 2025 12:16:49 +0200 Subject: [PATCH 056/123] chore: update dependencies (#9784) # Which Problems Are Solved Some dependencies are out of date and published new version including (unaffected) vulnerability fixes. # How the Problems Are Solved - Updated at least all direct dependencies apart from i18n, webauthn (existing issues), - crewjam (https://github.com/zitadel/zitadel/issues/9783) and - github.com/gorilla/csrf (https://github.com/gorilla/csrf/issues/190, https://github.com/gorilla/csrf/issues/189, https://github.com/gorilla/csrf/issues/188, https://github.com/gorilla/csrf/issues/187, https://github.com/gorilla/csrf/issues/186) - noteworthy: https://github.com/golang/go/issues/73626 - Some dependencies require Go 1.24, which triggered an update for zitadel to go 1.24 as well. # Additional Changes None # Additional Context None --- .github/workflows/build.yml | 2 +- go.mod | 90 ++++--- go.sum | 254 +++++++----------- internal/api/oidc/error.go | 9 +- internal/protoc/protoc-gen-authoption/main.go | 2 +- internal/query/auth_request_test.go | 5 +- internal/query/saml_request_test.go | 5 +- 7 files changed, 150 insertions(+), 217 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c5e99b95b9..f06c4a959c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,7 +72,7 @@ jobs: with: node_version: "18" buf_version: "latest" - go_lint_version: "v1.62.2" + go_lint_version: "v1.64.8" core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} diff --git a/go.mod b/go.mod index 00f65c1331..ec86708942 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,12 @@ module github.com/zitadel/zitadel -go 1.23.7 +go 1.24 + +toolchain go1.24.1 require ( cloud.google.com/go/profiler v0.4.2 - cloud.google.com/go/storage v1.51.0 + cloud.google.com/go/storage v1.54.0 github.com/BurntSushi/toml v1.5.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.27.0 @@ -20,15 +22,15 @@ require ( github.com/crewjam/saml v0.4.14 github.com/descope/virtualwebauthn v1.0.3 github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c - github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0 + github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 github.com/drone/envsubst v1.0.3 github.com/envoyproxy/protoc-gen-validate v1.2.1 github.com/fatih/color v1.18.0 github.com/fergusstrange/embedded-postgres v1.30.0 - github.com/gabriel-vasile/mimetype v1.4.8 + github.com/gabriel-vasile/mimetype v1.4.9 github.com/go-chi/chi/v5 v5.2.1 - github.com/go-jose/go-jose/v4 v4.0.5 - github.com/go-ldap/ldap/v3 v3.4.10 + github.com/go-jose/go-jose/v4 v4.1.0 + github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-webauthn/webauthn v0.10.2 github.com/goccy/go-json v0.10.5 github.com/golang/protobuf v1.5.4 @@ -43,36 +45,36 @@ require ( github.com/h2non/gock v1.2.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/improbable-eng/grpc-web v0.15.0 - github.com/jackc/pgx/v5 v5.7.3 + github.com/jackc/pgx/v5 v5.7.5 github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 github.com/jinzhu/gorm v1.9.16 github.com/k3a/html2text v1.2.1 github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/minio/minio-go/v7 v7.0.88 + github.com/minio/minio-go/v7 v7.0.91 github.com/mitchellh/mapstructure v1.5.0 github.com/muesli/gamut v0.3.1 github.com/muhlemmer/gu v0.3.1 github.com/muhlemmer/httpforwarded v0.1.0 github.com/nicksnyder/go-i18n/v2 v2.4.0 - github.com/pashagolub/pgxmock/v4 v4.6.0 - github.com/pquerna/otp v1.4.0 + github.com/pashagolub/pgxmock/v4 v4.7.0 + github.com/pquerna/otp v1.5.0 github.com/rakyll/statik v0.1.7 - github.com/redis/go-redis/v9 v9.7.3 - github.com/riverqueue/river v0.19.0 - github.com/riverqueue/river/riverdriver v0.19.0 - github.com/riverqueue/river/rivertype v0.19.0 + github.com/redis/go-redis/v9 v9.8.0 + github.com/riverqueue/river v0.22.0 + github.com/riverqueue/river/riverdriver v0.22.0 + github.com/riverqueue/river/rivertype v0.22.0 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/sony/gobreaker/v2 v2.1.0 - github.com/sony/sonyflake v1.2.0 + github.com/sony/sonyflake v1.2.1 github.com/spf13/cobra v1.9.1 - github.com/spf13/viper v1.20.0 + github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 github.com/ttacon/libphonenumber v1.2.1 - github.com/twilio/twilio-go v1.24.1 + github.com/twilio/twilio-go v1.26.1 github.com/zitadel/exifremove v0.1.0 github.com/zitadel/logging v0.6.2 - github.com/zitadel/oidc/v3 v3.36.1 + github.com/zitadel/oidc/v3 v3.37.0 github.com/zitadel/passwap v0.9.0 github.com/zitadel/saml v0.3.5 github.com/zitadel/schema v1.3.1 @@ -86,26 +88,26 @@ require ( go.opentelemetry.io/otel/sdk v1.35.0 go.opentelemetry.io/otel/sdk/metric v1.35.0 go.opentelemetry.io/otel/trace v1.35.0 - go.uber.org/mock v0.5.0 - golang.org/x/crypto v0.37.0 - golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 - golang.org/x/net v0.37.0 - golang.org/x/oauth2 v0.28.0 - golang.org/x/sync v0.13.0 - golang.org/x/text v0.24.0 - google.golang.org/api v0.227.0 - google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4 - google.golang.org/grpc v1.71.0 - google.golang.org/protobuf v1.36.5 + go.uber.org/mock v0.5.2 + golang.org/x/crypto v0.38.0 + golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 + golang.org/x/net v0.40.0 + golang.org/x/oauth2 v0.30.0 + golang.org/x/sync v0.14.0 + golang.org/x/text v0.25.0 + google.golang.org/api v0.233.0 + google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 + google.golang.org/grpc v1.72.1 + google.golang.org/protobuf v1.36.6 sigs.k8s.io/yaml v1.4.0 ) require ( - cel.dev/expr v0.19.2 // indirect - cloud.google.com/go/auth v0.15.0 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect + cel.dev/expr v0.20.0 // indirect + cloud.google.com/go/auth v0.16.1 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/monitoring v1.24.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect @@ -130,7 +132,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/lib/pq v1.10.9 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -140,29 +142,31 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/riverqueue/river/rivershared v0.19.0 // indirect + github.com/riverqueue/river/rivershared v0.22.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/zeebo/errs v1.4.0 // indirect github.com/zenazn/goji v1.0.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.34.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.35.0 // indirect go.uber.org/goleak v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/time v0.11.0 // indirect google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 // indirect ) require ( - cloud.google.com/go v0.118.3 // indirect + cloud.google.com/go v0.121.0 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect - cloud.google.com/go/iam v1.4.1 // indirect + cloud.google.com/go/iam v1.5.2 // indirect cloud.google.com/go/trace v1.11.3 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/amdonov/xmlsig v0.1.0 // indirect @@ -185,7 +189,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect - github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect @@ -201,7 +205,7 @@ require ( github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jonboulle/clockwork v0.4.0 - github.com/klauspost/compress v1.17.11 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect @@ -213,7 +217,7 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/riverqueue/river/riverdriver/riverpgxv5 v0.19.0 + github.com/riverqueue/river/riverdriver/riverpgxv5 v0.22.0 github.com/rs/xid v1.6.0 // indirect github.com/russellhaering/goxmldsig v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 @@ -226,7 +230,7 @@ require ( github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/proto/otlp v1.5.0 // indirect - golang.org/x/sys v0.32.0 + golang.org/x/sys v0.33.0 gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect nhooyr.io/websocket v1.8.11 // indirect diff --git a/go.sum b/go.sum index f361fb87fa..6d54730acd 100644 --- a/go.sum +++ b/go.sum @@ -1,27 +1,27 @@ -cel.dev/expr v0.19.2 h1:V354PbqIXr9IQdwy4SYA4xa0HXaWq1BUPAGzugBY5V4= -cel.dev/expr v0.19.2/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= +cel.dev/expr v0.20.0 h1:OunBvVCfvpWlt4dN7zg3FM6TDkzOePe1+foGJ9AXeeI= +cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.118.3 h1:jsypSnrE/w4mJysioGdMBg4MiW/hHx/sArFpaBWHdME= -cloud.google.com/go v0.118.3/go.mod h1:Lhs3YLnBlwJ4KA6nuObNMZ/fCbOQBPuWKPoE0Wa/9Vc= -cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps= -cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8= -cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= -cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= +cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg= +cloud.google.com/go v0.121.0/go.mod h1:rS7Kytwheu/y9buoDmu5EIpMMCI4Mb8ND4aeN4Vwj7Q= +cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU= +cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= -cloud.google.com/go/iam v1.4.1 h1:cFC25Nv+u5BkTR/BT1tXdoF2daiVbZ1RLx2eqfQ9RMM= -cloud.google.com/go/iam v1.4.1/go.mod h1:2vUEJpUG3Q9p2UdsyksaKpDzlwOrnMzS30isdReIcLM= +cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= +cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= cloud.google.com/go/logging v1.13.0 h1:7j0HgAp0B94o1YRDqiqm26w4q1rDMH7XNRU34lJXHYc= cloud.google.com/go/logging v1.13.0/go.mod h1:36CoKh6KA/M0PbhPKMq6/qety2DCAErbhXT62TuXALA= -cloud.google.com/go/longrunning v0.6.5 h1:sD+t8DO8j4HKW4QfouCklg7ZC1qC4uzVZt8iz3uTW+Q= -cloud.google.com/go/longrunning v0.6.5/go.mod h1:Et04XK+0TTLKa5IPYryKf5DkpwImy6TluQ1QTLwlKmY= +cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= +cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= cloud.google.com/go/monitoring v1.24.0 h1:csSKiCJ+WVRgNkRzzz3BPoGjFhjPY23ZTcaenToJxMM= cloud.google.com/go/monitoring v1.24.0/go.mod h1:Bd1PRK5bmQBQNnuGwHBfUamAV1ys9049oEPHnn4pcsc= cloud.google.com/go/profiler v0.4.2 h1:KojCmZ+bEPIQrd7bo2UFvZ2xUPLHl55KzHl7iaR4V2I= cloud.google.com/go/profiler v0.4.2/go.mod h1:7GcWzs9deJHHdJ5J9V1DzKQ9JoIoTGhezwlLbwkOoCs= -cloud.google.com/go/storage v1.51.0 h1:ZVZ11zCiD7b3k+cH5lQs/qcNaoSz3U9I0jgwVzqDlCw= -cloud.google.com/go/storage v1.51.0/go.mod h1:YEJfu/Ki3i5oHC/7jyTgsGZwdQ8P9hqMqvpi5kRKGgc= +cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI+0= +cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -33,8 +33,8 @@ github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0 h1:ErKg/3iS1AKcTkf3yixlZ54f9U1rljCkQyEXWUnIUxc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 h1:fYE9p3esPxA/C0rQ0AHhP0drtPXDRhaWiwg1DPqO7IU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.27.0 h1:Jtr816GUk6+I2ox9L/v+VcOwN6IyGOEDTSNHfD6m9sY= @@ -157,8 +157,8 @@ github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yA github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c h1:mxWGS0YyquJ/ikZOjSrRjjFIbUqIP9ojyYQ+QZTU3Rg= github.com/dop251/goja v0.0.0-20250309171923-bcd7cc6bf64c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= -github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0 h1:jTwdYTGERaZ/3+glBUVQZV2NwGodd9HlkXJbTBUPLLo= -github.com/dop251/goja_nodejs v0.0.0-20250314160716-c55ecee183c0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= +github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0 h1:fuHXpEVTTk7TilRdfGRLHpiTD6tnT0ihEowCfWjlFvw= +github.com/dop251/goja_nodejs v0.0.0-20250409162600-f7acab6894b0/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0= github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d h1:ygcRCGNKuEiA98k7X35hknEN8RIRUF1jrz7k1rZCvsk= @@ -225,13 +225,13 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= -github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= -github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= -github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= -github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= @@ -242,14 +242,14 @@ github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3Bop github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= -github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= -github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= +github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= -github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= -github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= +github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= +github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= @@ -298,7 +298,6 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= @@ -342,7 +341,6 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk= @@ -378,10 +376,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -418,7 +414,6 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= @@ -441,14 +436,14 @@ github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANyt github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= -github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= -github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 h1:Dj0L5fhJ9F82ZJyVOmBx6msDp/kfd1t9GRfny/mfJA0= +github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo= -github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 h1:jny9eqYPwkG8IVy7foUoRjQmFLcArCSz+uPsL6KS0HQ= @@ -498,11 +493,11 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.11.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= -github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= @@ -553,8 +548,8 @@ github.com/minio/crc64nvme v1.0.1 h1:DHQPrYPdqK7jQG/Ls5CTBZWeex/2FMS3G5XGkycuFrY github.com/minio/crc64nvme v1.0.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM= -github.com/minio/minio-go/v7 v7.0.88 h1:v8MoIJjwYxOkehp+eiLIuvXk87P2raUtoU5klrAAshs= -github.com/minio/minio-go/v7 v7.0.88/go.mod h1:33+O8h0tO7pCeCWwBVa07RhVVfB/3vS4kEX7rwYKmIg= +github.com/minio/minio-go/v7 v7.0.91 h1:tWLZnEfo3OZl5PoXQwcwTAPNNrjyWwOh6cbZitW5JQc= +github.com/minio/minio-go/v7 v7.0.91/go.mod h1:uvMUcGrpgeSAAI6+sD3818508nUyMULw94j2Nxku/Go= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= @@ -614,8 +609,8 @@ github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnh github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pashagolub/pgxmock/v4 v4.6.0 h1:ds0hIs+bJtkfo01vqjp0BOFirjt4Ea8XV082uorzM3w= -github.com/pashagolub/pgxmock/v4 v4.6.0/go.mod h1:9VoVHXwS3XR/yPtKGzwQvwZX1kzGB9sM8SviDcHDa3A= +github.com/pashagolub/pgxmock/v4 v4.7.0 h1:de2ORuFYyjwOQR7NBm57+321RnZxpYiuUjsmqRiqgh8= +github.com/pashagolub/pgxmock/v4 v4.7.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM= github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= @@ -634,8 +629,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= -github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -669,22 +664,22 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG github.com/rakyll/statik v0.1.7 h1:OF3QCZUuyPxuGEP7B4ypUa7sB/iHtqOTDYZXGM8KOdQ= github.com/rakyll/statik v0.1.7/go.mod h1:AlZONWzMtEnMs7W4e/1LURLiI49pIMmp6V9Unghqrcc= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= -github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= -github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= +github.com/redis/go-redis/v9 v9.8.0 h1:q3nRvjrlge/6UD7eTu/DSg2uYiU2mCL0G/uzBWqhicI= +github.com/redis/go-redis/v9 v9.8.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= -github.com/riverqueue/river v0.19.0 h1:WRh/NXhp+WEEY0HpCYgr4wSRllugYBt30HtyQ3jlz08= -github.com/riverqueue/river v0.19.0/go.mod h1:YJ7LA2uBdqFHQJzKyYc+X6S04KJeiwsS1yU5a1rynlk= -github.com/riverqueue/river/riverdriver v0.19.0 h1:NyHz5DfB13paT2lvaO0CKmwy4SFLbA7n6MFRGRtwii4= -github.com/riverqueue/river/riverdriver v0.19.0/go.mod h1:Soxi08hHkEvopExAp6ADG2437r4coSiB4QpuIL5E28k= -github.com/riverqueue/river/riverdriver/riverdatabasesql v0.19.0 h1:ytdPnueiv7ANxJcntBtYenrYZZLY5P0mXoDV0l4WsLk= -github.com/riverqueue/river/riverdriver/riverdatabasesql v0.19.0/go.mod h1:5Fahb3n+m1V0RAb0JlOIpzimoTlkOgudMfxSSCTcmFk= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.19.0 h1:QWg7VTDDXbtTF6srr7Y1C888PiNzqv379yQuNSnH2hg= -github.com/riverqueue/river/riverdriver/riverpgxv5 v0.19.0/go.mod h1:uvF1YS+iSQavCIHtaB/Y6O8A6Dnn38ctVQCpCpmHDZE= -github.com/riverqueue/river/rivershared v0.19.0 h1:TZvFM6CC+QgwQQUMQ5Ueuhx25ptgqcKqZQGsdLJnFeE= -github.com/riverqueue/river/rivershared v0.19.0/go.mod h1:JAvmohuC5lounVk8e3zXZIs07Da3klzEeJo1qDQIbjw= -github.com/riverqueue/river/rivertype v0.19.0 h1:5rwgdh21pVcU9WjrHIIO9qC2dOMdRrrZ/HZZOE0JRyY= -github.com/riverqueue/river/rivertype v0.19.0/go.mod h1:DETcejveWlq6bAb8tHkbgJqmXWVLiFhTiEm8j7co1bE= +github.com/riverqueue/river v0.22.0 h1:PO4Ula2RqViQqNs6xjze7yFV6Zq4T3Ffv092+f4S8xQ= +github.com/riverqueue/river v0.22.0/go.mod h1:IRoWoK4RGCiPuVJUV4EWcCl9d/TMQYkk0EEYV/Wgq+U= +github.com/riverqueue/river/riverdriver v0.22.0 h1:i7OSFkUi6x4UKvttdFOIg7NYLYaBOFLJZvkZ0+JWS/8= +github.com/riverqueue/river/riverdriver v0.22.0/go.mod h1:oNdjJCeAJhN/UiZGLNL+guNqWaxMFuSD4lr5x/v/was= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.22.0 h1:+no3gToOK9SmWg0pDPKfOGSCsrxqqaFdD8K1NQndRbY= +github.com/riverqueue/river/riverdriver/riverdatabasesql v0.22.0/go.mod h1:mygiHa1dnlKRjxT1//wIvfT2fMTbfXKm37NcsxoyBoQ= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.22.0 h1:2TWbVL73gipJ2/4JNCQbifaNj+BCC/Zxpp30o1D8RTg= +github.com/riverqueue/river/riverdriver/riverpgxv5 v0.22.0/go.mod h1:TZY/BG8w/nDxkraAEvvgyVupIz0b4+PQVUW0kIiy1fc= +github.com/riverqueue/river/rivershared v0.22.0 h1:hLPHr98d6OEfmUJ4KpIXgoy2tbQ14htWILcRBHJF11U= +github.com/riverqueue/river/rivershared v0.22.0/go.mod h1:BK+hvhECfdDLWNDH3xiGI95m2YoPfVtECZLT+my8XM8= +github.com/riverqueue/river/rivertype v0.22.0 h1:rSRhbd5uV/BaFTPxReCxuYTAzx+/riBZJlZdREADvO4= +github.com/riverqueue/river/rivertype v0.22.0/go.mod h1:lmdl3vLNDfchDWbYdW2uAocIuwIN+ZaXqAukdSCFqWs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -725,8 +720,8 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY= github.com/sony/gobreaker/v2 v2.1.0 h1:av2BnjtRmVPWBvy5gSFPytm1J8BmN5AGhq875FfGKDM= github.com/sony/gobreaker/v2 v2.1.0/go.mod h1:dO3Q/nCzxZj6ICjH6J/gM0r4oAwBMVLY8YAQf+NTtUg= -github.com/sony/sonyflake v1.2.0 h1:Pfr3A+ejSg+0SPqpoAmQgEtNDAhc2G1SUYk205qVMLQ= -github.com/sony/sonyflake v1.2.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y= +github.com/sony/sonyflake v1.2.1 h1:Jzo4abS84qVNbYamXZdrZF1/6TzNJjEogRfXv7TsG48= +github.com/sony/sonyflake v1.2.1/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= @@ -740,23 +735,20 @@ github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY= -github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spiffe/go-spiffe/v2 v2.5.0 h1:N2I01KCUkv1FAjZXJMwh95KK1ZIQLYbPfhaxw8WS0hE= +github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g= github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw= github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stvp/tempredis v0.0.0-20181119212430-b82af8480203 h1:QVqDTf3h2WHt08YuiTGPZLls0Wq99X9bWd0Q5ZSBesM= @@ -778,8 +770,8 @@ github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2 h1:5u+EJUQiosu3JFX0 github.com/ttacon/builder v0.0.0-20170518171403-c099f663e1c2/go.mod h1:4kyMkleCiLkgY6z8gK5BkI01ChBtxR0ro3I1ZDcGM3w= github.com/ttacon/libphonenumber v1.2.1 h1:fzOfY5zUADkCkbIafAed11gL1sW+bJ26p6zWLBMElR4= github.com/ttacon/libphonenumber v1.2.1/go.mod h1:E0TpmdVMq5dyVlQ7oenAkhsLu86OkUl+yR4OAxyEg/M= -github.com/twilio/twilio-go v1.24.1 h1:bpBL1j5GRdJGSG+tCdo0O94BwK4uDOHQuNT5ndzljPg= -github.com/twilio/twilio-go v1.24.1/go.mod h1:zRkMjudW7v7MqQ3cWNZmSoZJ7EBjPZ4OpNh2zm7Q6ko= +github.com/twilio/twilio-go v1.26.1 h1:HazQUV+BCuW5CaJVMTjqV22V32LirwZNQBu98ADPQzM= +github.com/twilio/twilio-go v1.26.1/go.mod h1:FpgNWMoD8CFnmukpKq9RNpUSGXC0BwnbeKZj2YHlIkw= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= @@ -796,17 +788,18 @@ github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQut github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/errs v1.4.0 h1:XNdoD/RRMKP7HD0UhJnIzUy74ISdGGxURlYG8HSWSfM= +github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4= github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8aKg= github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek= github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= -github.com/zitadel/oidc/v3 v3.36.1 h1:1AT1NqKKEqAwx4GmKJZ9fYkWH2WIn/VKMfQ46nBtRf0= -github.com/zitadel/oidc/v3 v3.36.1/go.mod h1:dApGZLvWZTHRuxmcbQlW5d2XVjVYR3vGOdq536igmTs= +github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= +github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= github.com/zitadel/passwap v0.9.0 h1:QvDK8OHKdb73C0m+mwXvu87UJSBqix3oFwTVENHdv80= github.com/zitadel/passwap v0.9.0/go.mod h1:6QzwFjDkIr3FfudzSogTOx5Ydhq4046dRJtDM/kX+G8= github.com/zitadel/saml v0.3.5 h1:L1RKWS5y66cGepVxUGjx/WSBOtrtSpRA/J3nn5BJLOY= @@ -820,8 +813,8 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0 h1:JRxssobiPg23otYU5SbWtQC//snGVIM3Tx6QRzlQBao= -go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0 h1:bGvFt68+KTiAKFlacHW6AhA56GF2rS0bdD3aJYEnmzA= +go.opentelemetry.io/contrib/detectors/gcp v1.35.0/go.mod h1:qGWP8/+ILwMRIUf9uIVLloR1uo5ZYAslM4O6OqUi1DA= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= @@ -834,8 +827,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk= go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0 h1:WDdP9acbMYjbKIyJUhTvtzj601sVJOqgWdUxSdR/Ysc= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.29.0/go.mod h1:BLbf7zbNIONBLPwvFnwNHGj4zge8uTCM/UPIVW1Mq2I= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= @@ -855,8 +848,8 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/goleak v1.1.10/go.mod h1:8a7PlsEVH3e/a/GLqe5IIrQx6GzcnRmZEufDUTk4A7A= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= @@ -875,19 +868,13 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= -golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= +golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -903,11 +890,6 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -926,7 +908,6 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -938,24 +919,15 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= -golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= -golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -964,14 +936,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1002,44 +968,20 @@ golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= @@ -1064,17 +1006,13 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk= -google.golang.org/api v0.227.0 h1:QvIHF9IuyG6d6ReE+BNd11kIB8hZvjN8Z5xY5t21zYc= -google.golang.org/api v0.227.0/go.mod h1:EIpaG6MbTgQarWF5xJvX0eOJPK9n/5D4Bynb9j2HXvQ= +google.golang.org/api v0.233.0 h1:iGZfjXAJiUFSSaekVB7LzXl6tRfEKhUN7FkZN++07tI= +google.golang.org/api v0.233.0/go.mod h1:TCIVLLlcwunlMpZIhIp7Ltk77W+vUSdUKAAIlbxY44c= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1089,10 +1027,10 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE= google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE= -google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4 h1:IFnXJq3UPB3oBREOodn1v1aGQeZYQclEmvWRMN0PSsY= -google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:c8q6Z6OCqnfVIqUFJkCzKcrj8eCvUrz+K4KRzSTuANg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= +google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 h1:WvBuA5rjZx9SNIzgcU53OohgZy6lKSus++uY4xLaWKc= +google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:W3S/3np0/dPWsWLi1h/UymYctGXaGBM2StwzD0y140U= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2 h1:IqsN8hx+lWLqlN+Sc3DoMy/watjofWiU8sRFgQ8fhKM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -1107,8 +1045,8 @@ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= -google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= +google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -1119,8 +1057,8 @@ google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/oidc/error.go b/internal/api/oidc/error.go index 781a1d3815..829a15d4d2 100644 --- a/internal/api/oidc/error.go +++ b/internal/api/oidc/error.go @@ -42,10 +42,7 @@ func oidcError(err error) error { if statusCode < 500 { newOidcErr = oidc.ErrInvalidRequest } - return op.NewStatusError( - newOidcErr(). - WithParent(err). - WithDescription(zError.GetMessage()), - statusCode, - ) + oidcErr := newOidcErr().WithParent(err) + oidcErr.Description = zError.GetMessage() + return op.NewStatusError(oidcErr, statusCode) } diff --git a/internal/protoc/protoc-gen-authoption/main.go b/internal/protoc/protoc-gen-authoption/main.go index b0e78c6990..1d6ae21b77 100644 --- a/internal/protoc/protoc-gen-authoption/main.go +++ b/internal/protoc/protoc-gen-authoption/main.go @@ -88,7 +88,7 @@ func main() { } // Write the response to stdout, to be picked up by protoc - fmt.Fprintf(os.Stdout, string(out)) + fmt.Fprint(os.Stdout, string(out)) } func loadTemplate(templateData []byte) *template.Template { diff --git a/internal/query/auth_request_test.go b/internal/query/auth_request_test.go index 152a032cd8..12288d18cc 100644 --- a/internal/query/auth_request_test.go +++ b/internal/query/auth_request_test.go @@ -5,7 +5,6 @@ import ( "database/sql" "database/sql/driver" _ "embed" - "fmt" "regexp" "testing" "time" @@ -22,9 +21,7 @@ import ( ) func TestQueries_AuthRequestByID(t *testing.T) { - expQuery := regexp.QuoteMeta(fmt.Sprintf( - authRequestByIDQuery, - )) + expQuery := regexp.QuoteMeta(authRequestByIDQuery) cols := []string{ projection.AuthRequestColumnID, diff --git a/internal/query/saml_request_test.go b/internal/query/saml_request_test.go index 3a062ac5fd..019054d4fc 100644 --- a/internal/query/saml_request_test.go +++ b/internal/query/saml_request_test.go @@ -5,7 +5,6 @@ import ( "database/sql" "database/sql/driver" _ "embed" - "fmt" "regexp" "testing" @@ -20,9 +19,7 @@ import ( ) func TestQueries_SamlRequestByID(t *testing.T) { - expQuery := regexp.QuoteMeta(fmt.Sprintf( - samlRequestByIDQuery, - )) + expQuery := regexp.QuoteMeta(samlRequestByIDQuery) cols := []string{ projection.SamlRequestColumnID, From 6b07e57e5c8caab0b4d95ceef0db579c61b756ca Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Tue, 20 May 2025 09:32:09 +0200 Subject: [PATCH 057/123] test: fix list orgs test with sort (#9909) # Which Problems Are Solved List organization integration test fails sometimes due to incorrect sorting of results. # How the Problems Are Solved Add sorting column to request on list organizations endpoint and sort expected results. # Additional Changes None # Additional Context None --- internal/api/grpc/org/v2/integration_test/query_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/api/grpc/org/v2/integration_test/query_test.go b/internal/api/grpc/org/v2/integration_test/query_test.go index 86a2bd312b..cb7576455c 100644 --- a/internal/api/grpc/org/v2/integration_test/query_test.go +++ b/internal/api/grpc/org/v2/integration_test/query_test.go @@ -5,7 +5,9 @@ package org_test import ( "context" "fmt" + "slices" "strconv" + "strings" "testing" "time" @@ -89,6 +91,7 @@ func TestServer_ListOrganizations(t *testing.T) { Queries: []*org.SearchQuery{ OrganizationIdQuery(Instance.DefaultOrg.Id), }, + SortingColumn: org.OrganizationFieldName_ORGANIZATION_FIELD_NAME_NAME, }, func(ctx context.Context, request *org.ListOrganizationsRequest) ([]orgAttr, error) { count := 3 @@ -101,6 +104,10 @@ func TestServer_ListOrganizations(t *testing.T) { request.Queries = []*org.SearchQuery{ OrganizationNamePrefixQuery(prefix), } + + slices.SortFunc(orgs, func(a, b orgAttr) int { + return -1 * strings.Compare(a.Name, b.Name) + }) return orgs, nil }, }, From 7861024ea2913a871ae62eaf9bc2e1a82dda78db Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Tue, 20 May 2025 14:21:30 +0200 Subject: [PATCH 058/123] docs: fix Go backend example (#9864) # Which Problems Are Solved This PR aims to clarify how to use the zitadel SDK with OAuth token introspection. # How the Problems Are Solved Reworked the setup process on console needed to create the JSON key and a PAT. # Additional Changes - Closes #5559 --- docs/docs/examples/secure-api/go.md | 74 +++++++++++++----- docs/static/img/go/api-PAT_creation.png | Bin 0 -> 54389 bytes docs/static/img/go/api-PAT_view.png | Bin 0 -> 139720 bytes docs/static/img/go/api-app_details.png | Bin 0 -> 213443 bytes docs/static/img/go/api-create-auth.png | Bin 103599 -> 0 bytes docs/static/img/go/api-create-key.png | Bin 31504 -> 0 bytes docs/static/img/go/api-create.png | Bin 182604 -> 0 bytes docs/static/img/go/api-create_application.png | Bin 0 -> 135770 bytes .../static/img/go/api-create_service_user.png | Bin 0 -> 43016 bytes docs/static/img/go/api-download_key.png | Bin 0 -> 66369 bytes docs/static/img/go/api-expiration_date.png | Bin 0 -> 65018 bytes docs/static/img/go/api-new_key.png | Bin 0 -> 58790 bytes docs/static/img/go/api-select_framework.png | Bin 0 -> 85996 bytes docs/static/img/go/api-select_jwt.png | Bin 0 -> 119147 bytes docs/static/img/go/api-service_user_panel.png | Bin 0 -> 116026 bytes 15 files changed, 55 insertions(+), 19 deletions(-) create mode 100644 docs/static/img/go/api-PAT_creation.png create mode 100644 docs/static/img/go/api-PAT_view.png create mode 100644 docs/static/img/go/api-app_details.png delete mode 100644 docs/static/img/go/api-create-auth.png delete mode 100644 docs/static/img/go/api-create-key.png delete mode 100644 docs/static/img/go/api-create.png create mode 100644 docs/static/img/go/api-create_application.png create mode 100644 docs/static/img/go/api-create_service_user.png create mode 100644 docs/static/img/go/api-download_key.png create mode 100644 docs/static/img/go/api-expiration_date.png create mode 100644 docs/static/img/go/api-new_key.png create mode 100644 docs/static/img/go/api-select_framework.png create mode 100644 docs/static/img/go/api-select_jwt.png create mode 100644 docs/static/img/go/api-service_user_panel.png diff --git a/docs/docs/examples/secure-api/go.md b/docs/docs/examples/secure-api/go.md index ab45e2a6b7..90fe7830be 100644 --- a/docs/docs/examples/secure-api/go.md +++ b/docs/docs/examples/secure-api/go.md @@ -10,26 +10,63 @@ At the end of the guide you should have an API with a protected endpoint. > This documentation references our HTTP example. There's also one for GRPC. Check them out on [GitHub](https://github.com/zitadel/zitadel-go/blob/next/example/api/http/main.go). -## Set up application and obtain keys - -Before we begin developing our API, we need to perform a few configuration steps in the ZITADEL Console. -You'll need to provide some information about your app. We recommend creating a new app to start from scratch. Navigate to your Project, then add a new application at the top of the page. -Select the **API** application type and continue. - -![Create app in console](/img/go/api-create.png) - -We recommend that you use JWT Profile for authenticating at the Introspection Endpoint. - -![Create app in console](/img/go/api-create-auth.png) - -Then create a new key with your desired expiration date. Be sure to download it, as you won't be able to retrieve it again. - -![Create api key in console](/img/go/api-create-key.png) - ## Prerequisites This will handle the OAuth 2.0 introspection request including authentication using JWT with Private Key using our [OIDC client library](https://github.com/zitadel/oidc). -All that is required, is to create your API and download the private key file later called `Key JSON` for the service user. +All that is required, is to create your API, create a private key and a personal access token for a service user. + +### Set up application and obtain keys + +Before we begin developing our API, we need to perform a few configuration steps in the ZITADEL Console. +You'll need to provide some information about your app. We recommend creating a new app to start from scratch. + +Starting from the homepage of your console, click on Create Application + +![Create app in homepage](/img/go/api-create_application.png) + +Select a project from the dropdown and select *Other* as framework, then continue. + +![Framework Selection](/img/go/api-select_framework.png) + +Add your app name and select *API* as application type, then continue. + +![Application Type](/img/go/api-app_details.png) + +We recommend that you use JWT Profile for authenticating at the Introspection Endpoint. So select *JWT* as authentication method + +![JWT authentication method](/img/go/api-select_jwt.png) + +You then need to create a new JSON key. + +![New JSON key](/img/go/api-new_key.png) + +Select an expiration date that suits you. + +![Key expiration date](/img/go/api-expiration_date.png) + +And make sure to download it, as you won't be able to retrieve it again. + +![Key download](/img/go/api-download_key.png) + +Now we need to create a *Personal Access Token* to authenticate the client requests. + +On the user view, switch to *Service Users* and create a new one. + +![Service User Panel](/img/go/api-service_user_panel.png) + +Give the service user a name and a user name. Select `Bearer` as *Access Token Type*. + +![Service User Creation](/img/go/api-create_service_user.png) + +### Create service user and personal access token (PAT) + +Once done, from the left panel of the user management, click on Personal Access Token and create a new one. + +![Personal Access Token View](/img/go/api-PAT_view.png) + +Set an expiration date and then copy the PAT generated to somewhere safe. We will need it later. + +![PAT creation](/img/go/api-PAT_creation.png) ## Go Setup @@ -119,8 +156,7 @@ Content-Length: 44 unauthorized: authorization header is empty ``` -Get a valid access_token for the API. You can either achieve this by getting an access token with the project_id in the audience -or use a PAT of a service account. +We need to use the personal access token generated previously. If you provide a valid Bearer Token: diff --git a/docs/static/img/go/api-PAT_creation.png b/docs/static/img/go/api-PAT_creation.png new file mode 100644 index 0000000000000000000000000000000000000000..b8d2032f05fa4d5450c3e5063969573c2a68f25f GIT binary patch literal 54389 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/go/api-PAT_view.png b/docs/static/img/go/api-PAT_view.png new file mode 100644 index 0000000000000000000000000000000000000000..c516f2ae2f5d68508c1e8b890ebe976e44c76baa GIT binary patch literal 139720 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 z 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 U>ZS;7r-O%|Gdo*gc;oK>0Q@Sw_y7O^ literal 0 HcmV?d00001 diff --git a/docs/static/img/go/api-app_details.png b/docs/static/img/go/api-app_details.png new file mode 100644 index 0000000000000000000000000000000000000000..730152e937bd8d16446fd92b8236a835d7ddb9f2 GIT binary patch literal 213443 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%0A;f>8 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 z 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

Sho|pMezj#x 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

K9W-%Q$ZI3QdJ+Uoyk}kETtM%~sWQQ>j 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

&fbyJ+n|P~C$se-EHzl0mg4CdeAD( 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

?3dV)Sn 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%0DrI#`xv2rN%&4t-=8nX$ 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 z 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 qP4a)PsMgUmf>6?4vyjFwwzTcf7hpwJG( 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 Iy~1z*A6)x4;Q#;t literal 0 HcmV?d00001 diff --git a/docs/static/img/go/api-select_framework.png b/docs/static/img/go/api-select_framework.png new file mode 100644 index 0000000000000000000000000000000000000000..aa7f112fe9454e3333ba9be39d1382ebfa4d5e70 GIT binary patch literal 85996 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 Q&3Y$X$KYzQmi6QR0Vv9hVgLXD literal 0 HcmV?d00001 diff --git a/docs/static/img/go/api-select_jwt.png b/docs/static/img/go/api-select_jwt.png new file mode 100644 index 0000000000000000000000000000000000000000..161c096b171a699c6eb2d765d37b428df7b588c5 GIT binary patch literal 119147 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 a{b!tUw(`ru*+m|pG~ns#=d#Wzp$PzXy)XR$ literal 0 HcmV?d00001 diff --git a/docs/static/img/go/api-service_user_panel.png b/docs/static/img/go/api-service_user_panel.png new file mode 100644 index 0000000000000000000000000000000000000000..d4e9b5beff9e54da0ada41d1209afb064e637af3 GIT binary patch literal 116026 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%0ABF0OAg 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 literal 0 HcmV?d00001 From 6929c680c4d99ab4a0700ea8a33e13fc49ca817d Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Tue, 20 May 2025 16:14:30 +0200 Subject: [PATCH 059/123] docs: specify deprecations (#9915) # Which Problems Are Solved We have no standard way of deprecating API methods. # How the Problems Are Solved The API_DESIGN.md contains a section that describes how to deprecate APIs. Most importantly, deprecated APIs should link to replacement APIs for good UX. # Additional Context - [x] Discussed with @stebenz during review of https://github.com/zitadel/zitadel/pull/9743#discussion_r2081736144 - [ ] Inform backend engineers when this is merged. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- API_DESIGN.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/API_DESIGN.md b/API_DESIGN.md index ac7c4a01e0..9e77657ab0 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -48,6 +48,52 @@ When creating a new service, start with version `2`, as version `1` is reserved Please check out the structure Buf style guide for more information about the folder and package structure: https://buf.build/docs/best-practices/style-guide/ +### Deprecations + +As a rule of thumb, redundant API methods are deprecated. + +- The proto option `grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation.deprecated` MUST be set to true. +- One or more links to recommended replacement methods MUST be added to the deprecation message as a proto comment above the rpc spec. +- Guidance for switching to the recommended methods for common use cases SHOULD be added as a proto comment above the rpc spec. + +#### Example + +```protobuf +// Delete the user phone +// +// Deprecated: [Update the user's phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx) to remove the phone number. +// +// Delete the phone number of a user. +rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/phone" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } + }; +} +``` + ### Explicitness Make the handling of the API as explicit as possible. Do not make assumptions about the client's knowledge of the system or the API. From a73acbcfc304b49642b3cf35cda0acb020cbc4f4 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 20 May 2025 19:18:32 +0200 Subject: [PATCH 060/123] fix(login): render error properly when auto creation fails (#9871) # Which Problems Are Solved If an IdP has the `automatic creation` option enabled without the `account creation allowed (manually)` and does not provide all the information required (given name, family name, ...) the wrong error message was presented to the user. # How the Problems Are Solved Prevent overwrite of the error when rendering the error in the `renderExternalNotFoundOption` function. # Additional Changes none # Additional Context - closes #9766 - requires backport to 2.x and 3.x Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- .../api/ui/login/external_provider_handler.go | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index d198978f1a..bd7ba7cd58 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -639,9 +639,10 @@ func (l *Login) renderExternalNotFoundOption(w http.ResponseWriter, r *http.Requ } resourceOwner := determineResourceOwner(r.Context(), authReq) if orgIAMPolicy == nil { - orgIAMPolicy, err = l.getOrgDomainPolicy(r, resourceOwner) - if err != nil { - l.renderError(w, r, authReq, err) + var policyErr error + orgIAMPolicy, policyErr = l.getOrgDomainPolicy(r, resourceOwner) + if policyErr != nil { + l.renderError(w, r, authReq, policyErr) return } } @@ -652,19 +653,22 @@ func (l *Login) renderExternalNotFoundOption(w http.ResponseWriter, r *http.Requ human, idpLink, _ = mapExternalUserToLoginUser(linkingUser, orgIAMPolicy.UserLoginMustBeDomain) } - labelPolicy, err := l.getLabelPolicy(r, resourceOwner) - if err != nil { - l.renderError(w, r, authReq, err) + labelPolicy, policyErr := l.getLabelPolicy(r, resourceOwner) + if policyErr != nil { + l.renderError(w, r, authReq, policyErr) return } - idpTemplate, err := l.getIDPByID(r, idpLink.IDPConfigID) - if err != nil { - l.renderError(w, r, authReq, err) + idpTemplate, idpErr := l.getIDPByID(r, idpLink.IDPConfigID) + if idpErr != nil { + l.renderError(w, r, authReq, idpErr) return } if !idpTemplate.IsCreationAllowed && !idpTemplate.IsLinkingAllowed { - l.renderError(w, r, authReq, zerrors.ThrowPreconditionFailed(nil, "LOGIN-3kl44", "Errors.User.ExternalIDP.NoOptionAllowed")) + if err == nil { + err = zerrors.ThrowPreconditionFailed(nil, "LOGIN-3kl44", "Errors.User.ExternalIDP.NoOptionAllowed") + } + l.renderError(w, r, authReq, err) return } From 490e4bd623c4c87cb8dd683d500a0bcb795f7da6 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Wed, 21 May 2025 10:50:44 +0200 Subject: [PATCH 061/123] feat: instance requests implementation for resource API (#9830) # Which Problems Are Solved These changes introduce resource-based API endpoints for managing instances and custom domains. There are 4 types of changes: - Endpoint implementation: consisting of the protobuf interface and the implementation of the endpoint. E.g: 606439a17227b629c1d018842dc3f1c569e4627a - (Integration) Tests: testing the implemented endpoint. E.g: cdfe1f0372b30cb74e34f0f23c6ada776e4477e9 - Fixes: Bugs found during development that are being fixed. E.g: acbbeedd3259b785948c1d702eb98f5810b3e60a - Miscellaneous: code needed to put everything together or that doesn't fit any of the above categories. E.g: 529df92abce1ffd69c0b3214bd835be404fd0de0 or 6802cb5468fbe24664ae6639fd3a40679222a2fd # How the Problems Are Solved _Ticked checkboxes indicate that the functionality is complete_ - [x] Instance - [x] Create endpoint - [x] Create endpoint tests - [x] Update endpoint - [x] Update endpoint tests - [x] Get endpoint - [x] Get endpoint tests - [x] Delete endpoint - [x] Delete endpoint tests - [x] Custom Domains - [x] Add custom domain - [x] Add custom domain tests - [x] Remove custom domain - [x] Remove custom domain tests - [x] List custom domains - [x] List custom domains tests - [x] Trusted Domains - [x] Add trusted domain - [x] Add trusted domain tests - [x] Remove trusted domain - [x] Remove trusted domain tests - [x] List trusted domains - [x] List trusted domains tests # Additional Changes When looking for instances (through the `ListInstances` endpoint) matching a given query, if you ask for the results to be order by a specific column, the query will fail due to a syntax error. This is fixed in acbbeedd3259b785948c1d702eb98f5810b3e60a . Further explanation can be found in the commit message # Additional Context - Relates to #9452 - CreateInstance has been excluded: https://github.com/zitadel/zitadel/issues/9930 - Permission checks / instance retrieval (middleware) needs to be changed to allow context based permission checks (https://github.com/zitadel/zitadel/issues/9929), required for ListInstances --------- Co-authored-by: Livio Spring --- cmd/start/start.go | 4 + docs/README.md | 2 + docs/docusaurus.config.js | 8 + docs/sidebars.js | 19 + .../api/grpc/instance/v2beta/converter.go | 246 +++++++ .../grpc/instance/v2beta/converter_test.go | 390 +++++++++++ internal/api/grpc/instance/v2beta/domain.go | 50 ++ internal/api/grpc/instance/v2beta/instance.go | 32 + .../v2beta/integration_test/domain_test.go | 350 ++++++++++ .../v2beta/integration_test/instance_test.go | 162 +++++ .../v2beta/integration_test/query_test.go | 369 ++++++++++ internal/api/grpc/instance/v2beta/query.go | 70 ++ internal/api/grpc/instance/v2beta/server.go | 60 ++ internal/command/instance.go | 10 +- internal/command/instance_test.go | 28 +- internal/command/instance_trusted_domain.go | 8 + .../command/instance_trusted_domain_test.go | 39 ++ internal/integration/client.go | 7 + internal/query/instance.go | 27 +- internal/query/instance_test.go | 11 +- proto/zitadel/admin.proto | 16 +- proto/zitadel/instance/v2beta/instance.proto | 192 ++++++ .../instance/v2beta/instance_service.proto | 648 ++++++++++++++++++ proto/zitadel/system.proto | 54 +- 24 files changed, 2781 insertions(+), 21 deletions(-) create mode 100644 internal/api/grpc/instance/v2beta/converter.go create mode 100644 internal/api/grpc/instance/v2beta/converter_test.go create mode 100644 internal/api/grpc/instance/v2beta/domain.go create mode 100644 internal/api/grpc/instance/v2beta/instance.go create mode 100644 internal/api/grpc/instance/v2beta/integration_test/domain_test.go create mode 100644 internal/api/grpc/instance/v2beta/integration_test/instance_test.go create mode 100644 internal/api/grpc/instance/v2beta/integration_test/query_test.go create mode 100644 internal/api/grpc/instance/v2beta/query.go create mode 100644 internal/api/grpc/instance/v2beta/server.go create mode 100644 proto/zitadel/instance/v2beta/instance.proto create mode 100644 proto/zitadel/instance/v2beta/instance_service.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 52d9c6fba8..6f04e8ee82 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -40,6 +40,7 @@ import ( feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" idp_v2 "github.com/zitadel/zitadel/internal/api/grpc/idp/v2" + instance "github.com/zitadel/zitadel/internal/api/grpc/instance/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/management" oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2" oidc_v2beta "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2beta" @@ -442,6 +443,9 @@ func startAPIs( if err := apis.RegisterServer(ctx, system.CreateServer(commands, queries, config.Database.DatabaseName(), config.DefaultInstance, config.ExternalDomain), tlsConfig); err != nil { return nil, err } + if err := apis.RegisterService(ctx, instance.CreateServer(commands, queries, config.Database.DatabaseName(), config.DefaultInstance, config.ExternalDomain)); err != nil { + return nil, err + } if err := apis.RegisterServer(ctx, admin.CreateServer(config.Database.DatabaseName(), commands, queries, keys.User, config.AuditLogRetention), tlsConfig); err != nil { return nil, err } diff --git a/docs/README.md b/docs/README.md index 92d3f8f279..34803a3629 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,8 @@ This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern To add a new site to the already existing structure simply save the `md` file into the corresponding folder and append the sites id int the file `sidebars.js`. +If you are introducing new APIs (gRPC), you need to add a new entry to `docusaurus.config.js` under the `plugins` section. + ## Installation Install dependencies with diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 1f45a017ac..6a4429cffe 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -364,6 +364,14 @@ module.exports = { categoryLinkSource: "auto", }, }, + instance_v2: { + specPath: ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", + outputDir: "docs/apis/resources/instance_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, }, }, ], diff --git a/docs/sidebars.js b/docs/sidebars.js index 92c7a00b2d..b7dc3fd8b8 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -13,6 +13,7 @@ const sidebar_api_org_service_v2 = require("./docs/apis/resources/org_service_v2 const sidebar_api_idp_service_v2 = require("./docs/apis/resources/idp_service_v2/sidebar.ts").default const sidebar_api_actions_v2 = require("./docs/apis/resources/action_service_v2/sidebar.ts").default const sidebar_api_webkey_service_v2 = require("./docs/apis/resources/webkey_service_v2/sidebar.ts").default +const sidebar_api_instance_service_v2 = require("./docs/apis/resources/instance_service_v2/sidebar.ts").default module.exports = { guides: [ @@ -840,6 +841,24 @@ module.exports = { }, items: sidebar_api_actions_v2, }, + { + type: "category", + label: "Instance (Beta)", + link: { + type: "generated-index", + title: "Instance Service API (Beta)", + slug: "/apis/resources/instance_service_v2", + description: + "This API is intended to manage instances, custom domains and trusted domains in ZITADEL.\n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ + "\n" + + "This v2 of the API provides the same functionalities as the v1, but organised on a per resource basis.\n" + + "The whole functionality related to domains (custom and trusted) has been moved under this instance API." + , + }, + items: sidebar_api_instance_service_v2, + }, ], }, { diff --git a/internal/api/grpc/instance/v2beta/converter.go b/internal/api/grpc/instance/v2beta/converter.go new file mode 100644 index 0000000000..8bff682606 --- /dev/null +++ b/internal/api/grpc/instance/v2beta/converter.go @@ -0,0 +1,246 @@ +package instance + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/cmd/build" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +func InstancesToPb(instances []*query.Instance) []*instance.Instance { + list := []*instance.Instance{} + for _, instance := range instances { + list = append(list, ToProtoObject(instance)) + } + return list +} + +func ToProtoObject(inst *query.Instance) *instance.Instance { + return &instance.Instance{ + Id: inst.ID, + Name: inst.Name, + Domains: DomainsToPb(inst.Domains), + Version: build.Version(), + ChangeDate: timestamppb.New(inst.ChangeDate), + CreationDate: timestamppb.New(inst.CreationDate), + } +} + +func DomainsToPb(domains []*query.InstanceDomain) []*instance.Domain { + d := []*instance.Domain{} + for _, dm := range domains { + pbDomain := DomainToPb(dm) + d = append(d, pbDomain) + } + return d +} + +func DomainToPb(d *query.InstanceDomain) *instance.Domain { + return &instance.Domain{ + Domain: d.Domain, + Primary: d.IsPrimary, + Generated: d.IsGenerated, + InstanceId: d.InstanceID, + CreationDate: timestamppb.New(d.CreationDate), + } +} + +func ListInstancesRequestToModel(req *instance.ListInstancesRequest, sysDefaults systemdefaults.SystemDefaults) (*query.InstanceSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := instanceQueriesToModel(req.GetQueries()) + if err != nil { + return nil, err + } + + return &query.InstanceSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToInstanceColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil + +} + +func fieldNameToInstanceColumn(fieldName instance.FieldName) query.Column { + switch fieldName { + case instance.FieldName_FIELD_NAME_ID: + return query.InstanceColumnID + case instance.FieldName_FIELD_NAME_NAME: + return query.InstanceColumnName + case instance.FieldName_FIELD_NAME_CREATION_DATE: + return query.InstanceColumnCreationDate + case instance.FieldName_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return query.Column{} + } +} + +func instanceQueriesToModel(queries []*instance.Query) (_ []query.SearchQuery, err error) { + q := []query.SearchQuery{} + for _, query := range queries { + model, err := instanceQueryToModel(query) + if err != nil { + return nil, err + } + q = append(q, model) + } + return q, nil +} + +func instanceQueryToModel(searchQuery *instance.Query) (query.SearchQuery, error) { + switch q := searchQuery.GetQuery().(type) { + case *instance.Query_IdQuery: + return query.NewInstanceIDsListSearchQuery(q.IdQuery.GetIds()...) + case *instance.Query_DomainQuery: + return query.NewInstanceDomainsListSearchQuery(q.DomainQuery.GetDomains()...) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "INST-3m0se", "List.Query.Invalid") + } +} + +func ListCustomDomainsRequestToModel(req *instance.ListCustomDomainsRequest, defaults systemdefaults.SystemDefaults) (*query.InstanceDomainSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(defaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := domainQueriesToModel(req.GetQueries()) + if err != nil { + return nil, err + } + + return &query.InstanceDomainSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToInstanceDomainColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func fieldNameToInstanceDomainColumn(fieldName instance.DomainFieldName) query.Column { + switch fieldName { + case instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN: + return query.InstanceDomainDomainCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED: + return query.InstanceDomainIsGeneratedCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY: + return query.InstanceDomainIsPrimaryCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE: + return query.InstanceDomainCreationDateCol + case instance.DomainFieldName_DOMAIN_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return query.Column{} + } +} + +func domainQueriesToModel(queries []*instance.DomainSearchQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = domainQueryToModel(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func domainQueryToModel(searchQuery *instance.DomainSearchQuery) (query.SearchQuery, error) { + switch q := searchQuery.GetQuery().(type) { + case *instance.DomainSearchQuery_DomainQuery: + return query.NewInstanceDomainDomainSearchQuery(object.TextMethodToQuery(q.DomainQuery.GetMethod()), q.DomainQuery.GetDomain()) + case *instance.DomainSearchQuery_GeneratedQuery: + return query.NewInstanceDomainGeneratedSearchQuery(q.GeneratedQuery.GetGenerated()) + case *instance.DomainSearchQuery_PrimaryQuery: + return query.NewInstanceDomainPrimarySearchQuery(q.PrimaryQuery.GetPrimary()) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid") + } +} + +func ListTrustedDomainsRequestToModel(req *instance.ListTrustedDomainsRequest, defaults systemdefaults.SystemDefaults) (*query.InstanceTrustedDomainSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(defaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := trustedDomainQueriesToModel(req.GetQueries()) + if err != nil { + return nil, err + } + + return &query.InstanceTrustedDomainSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: fieldNameToInstanceTrustedDomainColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func trustedDomainQueriesToModel(queries []*instance.TrustedDomainSearchQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = trustedDomainQueryToModel(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func trustedDomainQueryToModel(searchQuery *instance.TrustedDomainSearchQuery) (query.SearchQuery, error) { + switch q := searchQuery.GetQuery().(type) { + case *instance.TrustedDomainSearchQuery_DomainQuery: + return query.NewInstanceTrustedDomainDomainSearchQuery(object.TextMethodToQuery(q.DomainQuery.GetMethod()), q.DomainQuery.GetDomain()) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid") + } +} + +func trustedDomainsToPb(domains []*query.InstanceTrustedDomain) []*instance.TrustedDomain { + d := make([]*instance.TrustedDomain, len(domains)) + for i, domain := range domains { + d[i] = trustedDomainToPb(domain) + } + return d +} + +func trustedDomainToPb(d *query.InstanceTrustedDomain) *instance.TrustedDomain { + return &instance.TrustedDomain{ + Domain: d.Domain, + InstanceId: d.InstanceID, + CreationDate: timestamppb.New(d.CreationDate), + } +} + +func fieldNameToInstanceTrustedDomainColumn(fieldName instance.TrustedDomainFieldName) query.Column { + switch fieldName { + case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN: + return query.InstanceTrustedDomainDomainCol + case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE: + return query.InstanceTrustedDomainCreationDateCol + case instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED: + fallthrough + default: + return query.Column{} + } +} diff --git a/internal/api/grpc/instance/v2beta/converter_test.go b/internal/api/grpc/instance/v2beta/converter_test.go new file mode 100644 index 0000000000..150678010c --- /dev/null +++ b/internal/api/grpc/instance/v2beta/converter_test.go @@ -0,0 +1,390 @@ +package instance + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/cmd/build" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" +) + +func Test_InstancesToPb(t *testing.T) { + instances := []*query.Instance{ + { + ID: "instance1", + Name: "Instance One", + Domains: []*query.InstanceDomain{ + { + Domain: "example.com", + IsPrimary: true, + IsGenerated: false, + Sequence: 1, + CreationDate: time.Unix(123, 0), + ChangeDate: time.Unix(124, 0), + InstanceID: "instance1", + }, + }, + Sequence: 1, + CreationDate: time.Unix(123, 0), + ChangeDate: time.Unix(124, 0), + }, + } + + want := []*instance.Instance{ + { + Id: "instance1", + Name: "Instance One", + Domains: []*instance.Domain{ + { + Domain: "example.com", + Primary: true, + Generated: false, + InstanceId: "instance1", + CreationDate: ×tamppb.Timestamp{Seconds: 123}, + }, + }, + Version: build.Version(), + ChangeDate: ×tamppb.Timestamp{Seconds: 124}, + CreationDate: ×tamppb.Timestamp{Seconds: 123}, + }, + } + + got := InstancesToPb(instances) + assert.Equal(t, want, got) +} + +func Test_ListInstancesRequestToModel(t *testing.T) { + t.Parallel() + + searchInstanceByID, err := query.NewInstanceIDsListSearchQuery("instance1", "instance2") + require.Nil(t, err) + + tt := []struct { + testName string + inputRequest *instance.ListInstancesRequest + maxQueryLimit uint64 + expectedResult *query.InstanceSearchQueries + expectedError error + }{ + { + testName: "when query limit exceeds max query limit should return invalid argument error", + maxQueryLimit: 1, + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.FieldName_FIELD_NAME_ID.Enum(), + Queries: []*instance.Query{{Query: &instance.Query_IdQuery{IdQuery: &instance.IdsQuery{Ids: []string{"instance1", "instance2"}}}}}, + }, + expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + testName: "when valid request should return instance search query model", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.FieldName_FIELD_NAME_ID.Enum(), + Queries: []*instance.Query{{Query: &instance.Query_IdQuery{IdQuery: &instance.IdsQuery{Ids: []string{"instance1", "instance2"}}}}}, + }, + expectedResult: &query.InstanceSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 10, + Asc: true, + SortingColumn: query.InstanceColumnID, + }, + Queries: []query.SearchQuery{searchInstanceByID}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tc.maxQueryLimit} + + got, err := ListInstancesRequestToModel(tc.inputRequest, sysDefaults) + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, got) + + }) + } +} + +func Test_fieldNameToInstanceColumn(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fieldName instance.FieldName + want query.Column + }{ + { + name: "ID field", + fieldName: instance.FieldName_FIELD_NAME_ID, + want: query.InstanceColumnID, + }, + { + name: "Name field", + fieldName: instance.FieldName_FIELD_NAME_NAME, + want: query.InstanceColumnName, + }, + { + name: "Creation Date field", + fieldName: instance.FieldName_FIELD_NAME_CREATION_DATE, + want: query.InstanceColumnCreationDate, + }, + { + name: "Unknown field", + fieldName: instance.FieldName(99), + want: query.Column{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := fieldNameToInstanceColumn(tt.fieldName) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_instanceQueryToModel(t *testing.T) { + t.Parallel() + + searchInstanceByID, err := query.NewInstanceIDsListSearchQuery("instance1") + require.Nil(t, err) + + searchInstanceByDomain, err := query.NewInstanceDomainsListSearchQuery("example.com") + require.Nil(t, err) + + tests := []struct { + name string + searchQuery *instance.Query + want query.SearchQuery + wantErr bool + }{ + { + name: "ID Query", + searchQuery: &instance.Query{ + Query: &instance.Query_IdQuery{ + IdQuery: &instance.IdsQuery{ + Ids: []string{"instance1"}, + }, + }, + }, + want: searchInstanceByID, + wantErr: false, + }, + { + name: "Domain Query", + searchQuery: &instance.Query{ + Query: &instance.Query_DomainQuery{ + DomainQuery: &instance.DomainsQuery{ + Domains: []string{"example.com"}, + }, + }, + }, + want: searchInstanceByDomain, + wantErr: false, + }, + { + name: "Invalid Query", + searchQuery: &instance.Query{ + Query: nil, + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := instanceQueryToModel(tt.searchQuery) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func Test_ListCustomDomainsRequestToModel(t *testing.T) { + t.Parallel() + + querySearchRes, err := query.NewInstanceDomainDomainSearchQuery(query.TextEquals, "example.com") + require.Nil(t, err) + + queryGeneratedRes, err := query.NewInstanceDomainGeneratedSearchQuery(false) + require.Nil(t, err) + + tests := []struct { + name string + inputRequest *instance.ListCustomDomainsRequest + maxQueryLimit uint64 + expectedResult *query.InstanceDomainSearchQueries + expectedError error + }{ + { + name: "when query limit exceeds max query limit should return invalid argument error", + inputRequest: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_DOMAIN, + Queries: []*instance.DomainSearchQuery{ + { + Query: &instance.DomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{ + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + Domain: "example.com", + }, + }, + }, + }, + }, + maxQueryLimit: 1, + expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "when valid request should return domain search query model", + inputRequest: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_PRIMARY, + Queries: []*instance.DomainSearchQuery{ + { + Query: &instance.DomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, Domain: "example.com"}}, + }, + { + Query: &instance.DomainSearchQuery_GeneratedQuery{ + GeneratedQuery: &instance.DomainGeneratedQuery{Generated: false}}, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: &query.InstanceDomainSearchQueries{ + SearchRequest: query.SearchRequest{Offset: 0, Limit: 10, Asc: true, SortingColumn: query.InstanceDomainIsPrimaryCol}, + Queries: []query.SearchQuery{ + querySearchRes, + queryGeneratedRes, + }, + }, + expectedError: nil, + }, + { + name: "when invalid query should return error", + inputRequest: &instance.ListCustomDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_GENERATED, + Queries: []*instance.DomainSearchQuery{ + { + Query: nil, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit} + + got, err := ListCustomDomainsRequestToModel(tt.inputRequest, sysDefaults) + assert.Equal(t, tt.expectedError, err) + assert.Equal(t, tt.expectedResult, got) + }) + } +} + +func Test_ListTrustedDomainsRequestToModel(t *testing.T) { + t.Parallel() + + querySearchRes, err := query.NewInstanceTrustedDomainDomainSearchQuery(query.TextEquals, "example.com") + require.Nil(t, err) + + tests := []struct { + name string + inputRequest *instance.ListTrustedDomainsRequest + maxQueryLimit uint64 + expectedResult *query.InstanceTrustedDomainSearchQueries + expectedError error + }{ + { + name: "when query limit exceeds max query limit should return invalid argument error", + inputRequest: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_DOMAIN, + Queries: []*instance.TrustedDomainSearchQuery{ + { + Query: &instance.TrustedDomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{ + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + Domain: "example.com", + }, + }, + }, + }, + }, + maxQueryLimit: 1, + expectedError: zerrors.ThrowInvalidArgumentf(errors.New("given: 10, allowed: 1"), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "when valid request should return domain search query model", + inputRequest: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Queries: []*instance.TrustedDomainSearchQuery{ + { + Query: &instance.TrustedDomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, Domain: "example.com"}}, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: &query.InstanceTrustedDomainSearchQueries{ + SearchRequest: query.SearchRequest{Offset: 0, Limit: 10, Asc: true, SortingColumn: query.InstanceTrustedDomainCreationDateCol}, + Queries: []query.SearchQuery{querySearchRes}, + }, + expectedError: nil, + }, + { + name: "when invalid query should return error", + inputRequest: &instance.ListTrustedDomainsRequest{ + Pagination: &filter.PaginationRequest{Limit: 10, Offset: 0, Asc: true}, + Queries: []*instance.TrustedDomainSearchQuery{ + { + Query: nil, + }, + }, + }, + maxQueryLimit: 100, + expectedResult: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "INST-Ags42", "List.Query.Invalid"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + sysDefaults := systemdefaults.SystemDefaults{MaxQueryLimit: tt.maxQueryLimit} + + got, err := ListTrustedDomainsRequestToModel(tt.inputRequest, sysDefaults) + assert.Equal(t, tt.expectedError, err) + assert.Equal(t, tt.expectedResult, got) + }) + } +} diff --git a/internal/api/grpc/instance/v2beta/domain.go b/internal/api/grpc/instance/v2beta/domain.go new file mode 100644 index 0000000000..439c6e5d8d --- /dev/null +++ b/internal/api/grpc/instance/v2beta/domain.go @@ -0,0 +1,50 @@ +package instance + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +func (s *Server) AddCustomDomain(ctx context.Context, req *instance.AddCustomDomainRequest) (*instance.AddCustomDomainResponse, error) { + details, err := s.command.AddInstanceDomain(ctx, req.GetDomain()) + if err != nil { + return nil, err + } + return &instance.AddCustomDomainResponse{ + CreationDate: timestamppb.New(details.CreationDate), + }, nil +} + +func (s *Server) RemoveCustomDomain(ctx context.Context, req *instance.RemoveCustomDomainRequest) (*instance.RemoveCustomDomainResponse, error) { + details, err := s.command.RemoveInstanceDomain(ctx, req.GetDomain()) + if err != nil { + return nil, err + } + return &instance.RemoveCustomDomainResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) AddTrustedDomain(ctx context.Context, req *instance.AddTrustedDomainRequest) (*instance.AddTrustedDomainResponse, error) { + details, err := s.command.AddTrustedDomain(ctx, req.GetDomain()) + if err != nil { + return nil, err + } + return &instance.AddTrustedDomainResponse{ + CreationDate: timestamppb.New(details.CreationDate), + }, nil +} + +func (s *Server) RemoveTrustedDomain(ctx context.Context, req *instance.RemoveTrustedDomainRequest) (*instance.RemoveTrustedDomainResponse, error) { + details, err := s.command.RemoveTrustedDomain(ctx, req.GetDomain()) + if err != nil { + return nil, err + } + + return &instance.RemoveTrustedDomainResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} diff --git a/internal/api/grpc/instance/v2beta/instance.go b/internal/api/grpc/instance/v2beta/instance.go new file mode 100644 index 0000000000..b1c36e74bb --- /dev/null +++ b/internal/api/grpc/instance/v2beta/instance.go @@ -0,0 +1,32 @@ +package instance + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +func (s *Server) DeleteInstance(ctx context.Context, request *instance.DeleteInstanceRequest) (*instance.DeleteInstanceResponse, error) { + obj, err := s.command.RemoveInstance(ctx, request.GetInstanceId()) + if err != nil { + return nil, err + } + + return &instance.DeleteInstanceResponse{ + DeletionDate: timestamppb.New(obj.EventDate), + }, nil + +} + +func (s *Server) UpdateInstance(ctx context.Context, request *instance.UpdateInstanceRequest) (*instance.UpdateInstanceResponse, error) { + obj, err := s.command.UpdateInstance(ctx, request.GetInstanceName()) + if err != nil { + return nil, err + } + + return &instance.UpdateInstanceResponse{ + ChangeDate: timestamppb.New(obj.EventDate), + }, nil +} diff --git a/internal/api/grpc/instance/v2beta/integration_test/domain_test.go b/internal/api/grpc/instance/v2beta/integration_test/domain_test.go new file mode 100644 index 0000000000..a0e2011cfc --- /dev/null +++ b/internal/api/grpc/instance/v2beta/integration_test/domain_test.go @@ -0,0 +1,350 @@ +//go:build integration + +package instance_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +func TestAddCustomDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + inst := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.AddCustomDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: gofakeit.DomainName(), + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: gofakeit.DomainName(), + }, + inputContext: iamOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument (INST-28nlD)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: " " + gofakeit.DomainName(), + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Cleanup(func() { + if tc.expectedErrorMsg == "" { + inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{Domain: strings.TrimSpace(tc.inputRequest.Domain)}) + } + }) + + // Test + res, err := inst.Client.InstanceV2Beta.AddCustomDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) + } + }) + } +} + +func TestRemoveCustomDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + iamOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + + customDomain := gofakeit.DomainName() + + _, err := inst.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), Domain: customDomain}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), Domain: customDomain}) + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.RemoveCustomDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: "custom1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: "custom1", + }, + inputContext: iamOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument (INST-39nls)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveCustomDomainRequest{ + InstanceId: inst.ID(), + Domain: " " + customDomain, + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.RemoveCustomDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} + +func TestAddTrustedDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.AddTrustedDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + Domain: "trusted1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + Domain: "trusted1", + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when invalid domain should return invalid argument error", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + Domain: " ", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.InvalidArgument, + expectedErrorMsg: "Errors.Invalid.Argument (COMMA-Stk21)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.AddTrustedDomainRequest{ + InstanceId: inst.ID(), + Domain: " " + gofakeit.DomainName(), + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Cleanup(func() { + if tc.expectedErrorMsg == "" { + inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{Domain: strings.TrimSpace(tc.inputRequest.Domain)}) + } + }) + + // Test + res, err := inst.Client.InstanceV2Beta.AddTrustedDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + assert.NotNil(t, res) + assert.NotEmpty(t, res.GetCreationDate()) + } + }) + } +} + +func TestRemoveTrustedDomain(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + + trustedDomain := gofakeit.DomainName() + + _, err := inst.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), Domain: trustedDomain}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), Domain: trustedDomain}) + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + inputRequest *instance.RemoveTrustedDomainRequest + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: inst.ID(), + Domain: "trusted1", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: inst.ID(), + Domain: "trusted1", + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when valid request should return successful response", + inputRequest: &instance.RemoveTrustedDomainRequest{ + InstanceId: inst.ID(), + Domain: " " + trustedDomain, + }, + inputContext: ctxWithSysAuthZ, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.RemoveTrustedDomain(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + require.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} diff --git a/internal/api/grpc/instance/v2beta/integration_test/instance_test.go b/internal/api/grpc/instance/v2beta/integration_test/instance_test.go new file mode 100644 index 0000000000..5187bbc78d --- /dev/null +++ b/internal/api/grpc/instance/v2beta/integration_test/instance_test.go @@ -0,0 +1,162 @@ +//go:build integration + +package instance_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/integration" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestDeleteInstace(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + inst := integration.NewInstance(ctxWithSysAuthZ) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.DeleteInstanceRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedInstanceID string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: " ", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when invalid input should return invalid argument error", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID() + "invalid", + }, + inputContext: ctxWithSysAuthZ, + expectedErrorCode: codes.NotFound, + expectedErrorMsg: "Instance not found (COMMA-AE3GS)", + }, + { + testName: "when delete succeeds should return deletion date", + inputRequest: &instance.DeleteInstanceRequest{ + InstanceId: inst.ID(), + }, + inputContext: ctxWithSysAuthZ, + expectedInstanceID: inst.ID(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.DeleteInstance(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + require.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} + +func TestUpdateInstace(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + inst := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.UpdateInstanceRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedNewName string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID(), + InstanceName: " ", + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID(), + InstanceName: " ", + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when update succeeds should change instance name", + inputRequest: &instance.UpdateInstanceRequest{ + InstanceId: inst.ID(), + InstanceName: "an-updated-name", + }, + inputContext: ctxWithSysAuthZ, + expectedNewName: "an-updated-name", + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.UpdateInstance(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + if tc.expectedErrorMsg == "" { + + require.NotNil(t, res) + assert.NotEmpty(t, res.GetChangeDate()) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputContext, 20*time.Second) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + retrievedInstance, err := inst.Client.InstanceV2Beta.GetInstance(tc.inputContext, &instance.GetInstanceRequest{InstanceId: inst.ID()}) + require.Nil(tt, err) + assert.Equal(tt, tc.expectedNewName, retrievedInstance.GetInstance().GetName()) + }, retryDuration, tick, "timeout waiting for expected execution result") + } + }) + } +} diff --git a/internal/api/grpc/instance/v2beta/integration_test/query_test.go b/internal/api/grpc/instance/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..0828b006e3 --- /dev/null +++ b/internal/api/grpc/instance/v2beta/integration_test/query_test.go @@ -0,0 +1,369 @@ +//go:build integration + +package instance_test + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/zitadel/internal/integration" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestGetInstance(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputContext context.Context + expectedInstanceID string + expectedErrorMsg string + expectedErrorCode codes.Code + }{ + { + testName: "when unauthN context should return unauthN error", + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputContext: orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when request succeeds should return matching instance", + inputContext: ctxWithSysAuthZ, + expectedInstanceID: inst.ID(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.GetInstance(tc.inputContext, &instance.GetInstanceRequest{InstanceId: inst.ID()}) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.Equal(t, tc.expectedInstanceID, res.GetInstance().GetId()) + } + }) + } +} + +func TestListInstances(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + + instances := make([]*integration.Instance, 2) + inst := integration.NewInstance(ctxWithSysAuthZ) + inst2 := integration.NewInstance(ctxWithSysAuthZ) + instances[0], instances[1] = inst, inst2 + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst2.ID()}) + }) + + // Sort in descending order + slices.SortFunc(instances, func(i1, i2 *integration.Instance) int { + res := i1.Instance.Details.CreationDate.AsTime().Compare(i2.Instance.Details.CreationDate.AsTime()) + if res == 0 { + return res + } + return -res + }) + + orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + + tt := []struct { + testName string + inputRequest *instance.ListInstancesRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedInstances []string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when valid request with filter should return paginated response", + inputRequest: &instance.ListInstancesRequest{ + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.FieldName_FIELD_NAME_CREATION_DATE.Enum(), + Queries: []*instance.Query{ + { + Query: &instance.Query_IdQuery{ + IdQuery: &instance.IdsQuery{ + Ids: []string{inst.ID(), inst2.ID()}, + }, + }, + }, + }, + }, + inputContext: ctxWithSysAuthZ, + expectedInstances: []string{inst2.ID(), inst.ID()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.ListInstances(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + + require.Len(t, res.GetInstances(), len(tc.expectedInstances)) + + for i, ins := range res.GetInstances() { + assert.Equal(t, tc.expectedInstances[i], ins.GetId()) + } + } + }) + } +} + +func TestListCustomDomains(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + + orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + d1, d2 := "custom."+gofakeit.DomainName(), "custom."+gofakeit.DomainName() + + _, err := inst.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), Domain: d1}) + require.Nil(t, err) + _, err = inst.Client.InstanceV2Beta.AddCustomDomain(ctxWithSysAuthZ, &instance.AddCustomDomainRequest{InstanceId: inst.ID(), Domain: d2}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), Domain: d1}) + inst.Client.InstanceV2Beta.RemoveCustomDomain(ctxWithSysAuthZ, &instance.RemoveCustomDomainRequest{InstanceId: inst.ID(), Domain: d2}) + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.ListCustomDomainsRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedDomains []string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing"}, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Queries: []*instance.DomainSearchQuery{ + { + Query: &instance.DomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when valid request with filter should return paginated response", + inputRequest: &instance.ListCustomDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.DomainFieldName_DOMAIN_FIELD_NAME_CREATION_DATE, + Queries: []*instance.DomainSearchQuery{ + { + Query: &instance.DomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Domain: "custom", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: ctxWithSysAuthZ, + expectedDomains: []string{d1, d2}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.ListCustomDomains(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + domains := []string{} + for _, d := range res.GetDomains() { + domains = append(domains, d.GetDomain()) + } + + assert.Subset(t, domains, tc.expectedDomains) + } + }) + } +} + +func TestListTrustedDomains(t *testing.T) { + t.Parallel() + + // Given + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + ctxWithSysAuthZ := integration.WithSystemAuthorization(ctx) + inst := integration.NewInstance(ctxWithSysAuthZ) + + orgOwnerCtx := inst.WithAuthorization(context.Background(), integration.UserTypeOrgOwner) + d1, d2 := "trusted."+gofakeit.DomainName(), "trusted."+gofakeit.DomainName() + + _, err := inst.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), Domain: d1}) + require.Nil(t, err) + _, err = inst.Client.InstanceV2Beta.AddTrustedDomain(ctxWithSysAuthZ, &instance.AddTrustedDomainRequest{InstanceId: inst.ID(), Domain: d2}) + require.Nil(t, err) + + t.Cleanup(func() { + inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), Domain: d1}) + inst.Client.InstanceV2Beta.RemoveTrustedDomain(ctxWithSysAuthZ, &instance.RemoveTrustedDomainRequest{InstanceId: inst.ID(), Domain: d2}) + inst.Client.InstanceV2Beta.DeleteInstance(ctxWithSysAuthZ, &instance.DeleteInstanceRequest{InstanceId: inst.ID()}) + }) + + tt := []struct { + testName string + inputRequest *instance.ListTrustedDomainsRequest + inputContext context.Context + expectedErrorMsg string + expectedErrorCode codes.Code + expectedDomains []string + }{ + { + testName: "when invalid context should return unauthN error", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: context.Background(), + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + }, + inputContext: orgOwnerCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when valid request with filter should return paginated response", + inputRequest: &instance.ListTrustedDomainsRequest{ + InstanceId: inst.ID(), + Pagination: &filter.PaginationRequest{Offset: 0, Limit: 10}, + SortingColumn: instance.TrustedDomainFieldName_TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE, + Queries: []*instance.TrustedDomainSearchQuery{ + { + Query: &instance.TrustedDomainSearchQuery_DomainQuery{ + DomainQuery: &instance.DomainQuery{Domain: "trusted", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS}, + }, + }, + }, + }, + inputContext: ctxWithSysAuthZ, + expectedDomains: []string{d1, d2}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Test + res, err := inst.Client.InstanceV2Beta.ListTrustedDomains(tc.inputContext, tc.inputRequest) + + // Verify + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NotNil(t, res) + + domains := []string{} + for _, d := range res.GetTrustedDomain() { + domains = append(domains, d.GetDomain()) + } + + assert.Subset(t, domains, tc.expectedDomains) + } + }) + } +} diff --git a/internal/api/grpc/instance/v2beta/query.go b/internal/api/grpc/instance/v2beta/query.go new file mode 100644 index 0000000000..74f79313ea --- /dev/null +++ b/internal/api/grpc/instance/v2beta/query.go @@ -0,0 +1,70 @@ +package instance + +import ( + "context" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +func (s *Server) GetInstance(ctx context.Context, _ *instance.GetInstanceRequest) (*instance.GetInstanceResponse, error) { + inst, err := s.query.Instance(ctx, true) + if err != nil { + return nil, err + } + + return &instance.GetInstanceResponse{ + Instance: ToProtoObject(inst), + }, nil +} + +func (s *Server) ListInstances(ctx context.Context, req *instance.ListInstancesRequest) (*instance.ListInstancesResponse, error) { + queries, err := ListInstancesRequestToModel(req, s.systemDefaults) + if err != nil { + return nil, err + } + + instances, err := s.query.SearchInstances(ctx, queries) + if err != nil { + return nil, err + } + + return &instance.ListInstancesResponse{ + Instances: InstancesToPb(instances.Instances), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, instances.SearchResponse), + }, nil +} + +func (s *Server) ListCustomDomains(ctx context.Context, req *instance.ListCustomDomainsRequest) (*instance.ListCustomDomainsResponse, error) { + queries, err := ListCustomDomainsRequestToModel(req, s.systemDefaults) + if err != nil { + return nil, err + } + + domains, err := s.query.SearchInstanceDomains(ctx, queries) + if err != nil { + return nil, err + } + + return &instance.ListCustomDomainsResponse{ + Domains: DomainsToPb(domains.Domains), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), + }, nil +} + +func (s *Server) ListTrustedDomains(ctx context.Context, req *instance.ListTrustedDomainsRequest) (*instance.ListTrustedDomainsResponse, error) { + queries, err := ListTrustedDomainsRequestToModel(req, s.systemDefaults) + if err != nil { + return nil, err + } + + domains, err := s.query.SearchInstanceTrustedDomains(ctx, queries) + if err != nil { + return nil, err + } + + return &instance.ListTrustedDomainsResponse{ + TrustedDomain: trustedDomainsToPb(domains.Domains), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), + }, nil +} diff --git a/internal/api/grpc/instance/v2beta/server.go b/internal/api/grpc/instance/v2beta/server.go new file mode 100644 index 0000000000..aaeaa4cc8f --- /dev/null +++ b/internal/api/grpc/instance/v2beta/server.go @@ -0,0 +1,60 @@ +package instance + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" +) + +var _ instance.InstanceServiceServer = (*Server)(nil) + +type Server struct { + instance.UnimplementedInstanceServiceServer + command *command.Commands + query *query.Queries + systemDefaults systemdefaults.SystemDefaults + defaultInstance command.InstanceSetup + externalDomain string +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + database string, + defaultInstance command.InstanceSetup, + externalDomain string, +) *Server { + return &Server{ + command: command, + query: query, + defaultInstance: defaultInstance, + externalDomain: externalDomain, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + instance.RegisterInstanceServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return instance.InstanceService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return instance.InstanceService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return instance.InstanceService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return instance.RegisterInstanceServiceHandler +} diff --git a/internal/command/instance.go b/internal/command/instance.go index 1080168842..d71be53468 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -2,6 +2,7 @@ package command import ( "context" + "strings" "time" "github.com/zitadel/logging" @@ -666,7 +667,7 @@ func setupMessageTexts(validations *[]preparation.Validation, setupMessageTexts func (c *Commands) UpdateInstance(ctx context.Context, name string) (*domain.ObjectDetails, error) { instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID()) - validation := c.prepareUpdateInstance(instanceAgg, name) + validation := c.prepareUpdateInstance(instanceAgg, strings.TrimSpace(name)) cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validation) if err != nil { return nil, err @@ -885,7 +886,12 @@ func getSystemConfigWriteModel(ctx context.Context, filter preparation.FilterToQ } func (c *Commands) RemoveInstance(ctx context.Context, id string) (*domain.ObjectDetails, error) { - instanceAgg := instance.NewAggregate(id) + instID := strings.TrimSpace(id) + if instID == "" || len(instID) > 200 { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-VeS2zI", "Errors.Invalid.Argument") + } + + instanceAgg := instance.NewAggregate(instID) cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareRemoveInstance(instanceAgg)) if err != nil { return nil, err diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index 2ea248dfb1..16e51d844d 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -1257,7 +1257,7 @@ func TestCommandSide_UpdateInstance(t *testing.T) { }, args: args{ ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - name: "", + name: " ", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -1404,6 +1404,32 @@ func TestCommandSide_RemoveInstance(t *testing.T) { args args res res }{ + { + name: "instance empty, invalid argument error", + fields: fields{ + eventstore: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), " "), + instanceID: " ", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "instance too long, invalid argument error", + fields: fields{ + eventstore: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "averylonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonginstance"), + instanceID: "averylonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonginstance", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, { name: "instance not existing, not found error", fields: fields{ diff --git a/internal/command/instance_trusted_domain.go b/internal/command/instance_trusted_domain.go index f404e6665a..7d3e6abeb1 100644 --- a/internal/command/instance_trusted_domain.go +++ b/internal/command/instance_trusted_domain.go @@ -34,6 +34,14 @@ func (c *Commands) AddTrustedDomain(ctx context.Context, trustedDomain string) ( } func (c *Commands) RemoveTrustedDomain(ctx context.Context, trustedDomain string) (*domain.ObjectDetails, error) { + trustedDomain = strings.TrimSpace(trustedDomain) + if trustedDomain == "" || len(trustedDomain) > 253 { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-ajAzwu", "Errors.Invalid.Argument") + } + if !allowDomainRunes.MatchString(trustedDomain) { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-lfs3Te", "Errors.Instance.Domain.InvalidCharacter") + } + model := NewInstanceTrustedDomainsWriteModel(ctx) err := c.eventstore.FilterToQueryReducer(ctx, model) if err != nil { diff --git a/internal/command/instance_trusted_domain_test.go b/internal/command/instance_trusted_domain_test.go index 3caef90f01..4ba9f773ed 100644 --- a/internal/command/instance_trusted_domain_test.go +++ b/internal/command/instance_trusted_domain_test.go @@ -142,6 +142,45 @@ func TestCommands_RemoveTrustedDomain(t *testing.T) { args args want want }{ + { + name: "domain empty string, error", + fields: fields{ + eventstore: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + trustedDomain: " ", + }, + want: want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-ajAzwu", "Errors.Invalid.Argument"), + }, + }, + { + name: "domain invalid character, error", + fields: fields{ + eventstore: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + trustedDomain: "? ", + }, + want: want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-lfs3Te", "Errors.Instance.Domain.InvalidCharacter"), + }, + }, + { + name: "domain length exceeded, error", + fields: fields{ + eventstore: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + trustedDomain: "averylonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglongdomain", + }, + want: want{ + err: zerrors.ThrowInvalidArgument(nil, "COMMA-ajAzwu", "Errors.Invalid.Argument"), + }, + }, { name: "domain does not exists, error", fields: fields{ diff --git a/internal/integration/client.go b/internal/integration/client.go index f1bcfb41bd..bd9775a28a 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -26,6 +26,7 @@ import ( feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" "github.com/zitadel/zitadel/pkg/grpc/idp" idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/object/v2" object_v3alpha "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" @@ -70,6 +71,11 @@ type Client struct { UserV3Alpha user_v3alpha.ZITADELUsersClient SAMLv2 saml_pb.SAMLServiceClient SCIM *scim.Client + InstanceV2Beta instance.InstanceServiceClient +} + +func NewDefaultClient(ctx context.Context) (*Client, error) { + return newClient(ctx, loadedConfig.Host()) } func newClient(ctx context.Context, target string) (*Client, error) { @@ -103,6 +109,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), SAMLv2: saml_pb.NewSAMLServiceClient(cc), SCIM: scim.NewScimClient(target), + InstanceV2Beta: instance.NewInstanceServiceClient(cc), } return client, client.pollHealth(ctx) } diff --git a/internal/query/instance.go b/internal/query/instance.go index 1b3bb055cb..bb311cbb85 100644 --- a/internal/query/instance.go +++ b/internal/query/instance.go @@ -150,7 +150,7 @@ func (q *Queries) SearchInstances(ctx context.Context, queries *InstanceSearchQu ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - filter, query, scan := prepareInstancesQuery() + filter, query, scan := prepareInstancesQuery(queries.SortingColumn, queries.Asc) stmt, args, err := query(queries.toQuery(filter)).ToSql() if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-M9fow", "Errors.Query.SQLStatement") @@ -260,17 +260,20 @@ func (q *Queries) GetDefaultLanguage(ctx context.Context) language.Tag { return instance.DefaultLang } -func prepareInstancesQuery() (sq.SelectBuilder, func(sq.SelectBuilder) sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { +func prepareInstancesQuery(sortBy Column, isAscedingSort bool) (sq.SelectBuilder, func(sq.SelectBuilder) sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { instanceFilterTable := instanceTable.setAlias(InstancesFilterTableAlias) instanceFilterIDColumn := InstanceColumnID.setTable(instanceFilterTable) instanceFilterCountColumn := InstancesFilterTableAlias + ".count" - return sq.Select( - InstanceColumnID.identifier(), - countColumn.identifier(), - ).Distinct().From(instanceTable.identifier()). + + selector := sq.Select(InstanceColumnID.identifier(), countColumn.identifier()) + if !sortBy.isZero() { + selector = sq.Select(InstanceColumnID.identifier(), countColumn.identifier(), sortBy.identifier()) + } + + return selector.Distinct().From(instanceTable.identifier()). LeftJoin(join(InstanceDomainInstanceIDCol, InstanceColumnID)), func(builder sq.SelectBuilder) sq.SelectBuilder { - return sq.Select( + outerQuery := sq.Select( instanceFilterCountColumn, instanceFilterIDColumn.identifier(), InstanceColumnCreationDate.identifier(), @@ -292,6 +295,16 @@ func prepareInstancesQuery() (sq.SelectBuilder, func(sq.SelectBuilder) sq.Select LeftJoin(join(InstanceColumnID, instanceFilterIDColumn)). LeftJoin(join(InstanceDomainInstanceIDCol, instanceFilterIDColumn)). PlaceholderFormat(sq.Dollar) + + if !sortBy.isZero() { + sorting := sortBy.identifier() + if !isAscedingSort { + sorting += " DESC" + } + return outerQuery.OrderBy(sorting) + } + + return outerQuery }, func(rows *sql.Rows) (*Instances, error) { instances := make([]*Instance, 0) diff --git a/internal/query/instance_test.go b/internal/query/instance_test.go index 55b1c8314b..37adfc8605 100644 --- a/internal/query/instance_test.go +++ b/internal/query/instance_test.go @@ -70,7 +70,7 @@ func Test_InstancePrepares(t *testing.T) { { name: "prepareInstancesQuery no result", prepare: func() (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { - filter, query, scan := prepareInstancesQuery() + filter, query, scan := prepareInstancesQuery(Column{}, true) return query(filter), scan }, want: want{ @@ -85,7 +85,7 @@ func Test_InstancePrepares(t *testing.T) { { name: "prepareInstancesQuery one result", prepare: func() (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { - filter, query, scan := prepareInstancesQuery() + filter, query, scan := prepareInstancesQuery(Column{}, true) return query(filter), scan }, want: want{ @@ -149,7 +149,7 @@ func Test_InstancePrepares(t *testing.T) { { name: "prepareInstancesQuery multiple results", prepare: func() (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { - filter, query, scan := prepareInstancesQuery() + filter, query, scan := prepareInstancesQuery(Column{}, true) return query(filter), scan }, want: want{ @@ -253,7 +253,8 @@ func Test_InstancePrepares(t *testing.T) { IsPrimary: true, }, }, - }, { + }, + { ID: "id2", CreationDate: testNow, ChangeDate: testNow, @@ -282,7 +283,7 @@ func Test_InstancePrepares(t *testing.T) { { name: "prepareInstancesQuery sql err", prepare: func() (sq.SelectBuilder, func(*sql.Rows) (*Instances, error)) { - filter, query, scan := prepareInstancesQuery() + filter, query, scan := prepareInstancesQuery(Column{}, true) return query(filter), scan }, want: want{ diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index d9f8bee2c7..9033fd8668 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -307,6 +307,7 @@ service AdminService { }; } + // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/instance-service-list-custom-domains.api.mdx) instead to list custom domains rpc ListInstanceDomains(ListInstanceDomainsRequest) returns (ListInstanceDomainsResponse) { option (google.api.http) = { post: "/domains/_search"; @@ -319,10 +320,12 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "List Instance Domains"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are the URLs where ZITADEL is running." + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are the URLs where ZITADEL is running."; + deprecated: true; }; } + // Deprecated: Use [ListTrustedDomains](apis/resources/instance_service_v2/instance-service-list-trusted-domains.api.mdx) instead to list trusted domains rpc ListInstanceTrustedDomains(ListInstanceTrustedDomainsRequest) returns (ListInstanceTrustedDomainsResponse) { option (google.api.http) = { post: "/trusted_domains/_search"; @@ -335,10 +338,12 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "List Instance Trusted Domains"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts." + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts."; + deprecated: true; }; } + // Deprecated: Use [AddTrustedDomain](apis/resources/instance_service_v2/instance-service-add-trusted-domain.api.mdx) instead to add a trusted domain rpc AddInstanceTrustedDomain(AddInstanceTrustedDomainRequest) returns (AddInstanceTrustedDomainResponse) { option (google.api.http) = { post: "/trusted_domains"; @@ -352,10 +357,12 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "Add an Instance Trusted Domain"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts." + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts."; + deprecated: true; }; } + // Deprecated: Use [RemoveTrustedDomain](apis/resources/instance_service_v2/instance-service-remove-trusted-domain.api.mdx) instead to remove a trusted domain rpc RemoveInstanceTrustedDomain(RemoveInstanceTrustedDomainRequest) returns (RemoveInstanceTrustedDomainResponse) { option (google.api.http) = { delete: "/trusted_domains/{domain}"; @@ -368,7 +375,8 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "Remove an Instance Trusted Domain"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts." + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts."; + deprecated: true; }; } diff --git a/proto/zitadel/instance/v2beta/instance.proto b/proto/zitadel/instance/v2beta/instance.proto new file mode 100644 index 0000000000..21f6148490 --- /dev/null +++ b/proto/zitadel/instance/v2beta/instance.proto @@ -0,0 +1,192 @@ +syntax = "proto3"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "zitadel/object/v2/object.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +package zitadel.instance.v2beta; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta;instance"; + +message Instance { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + + // change_date is the timestamp when the object was changed + // + // on read: the timestamp of the last event reduced by the projection + // + // on manipulation: the timestamp of the event(s) added by the manipulation + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + google.protobuf.Timestamp creation_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + State state = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the instance"; + } + ]; + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + } + ]; + string version = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"1.0.0\""; + } + ]; + repeated Domain domains = 7; +} + +enum State { + STATE_UNSPECIFIED = 0; + STATE_CREATING = 1; + STATE_RUNNING = 2; + STATE_STOPPING = 3; + STATE_STOPPED = 4; +} + +message Domain { + string instance_id = 1; + + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + string domain = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.com\"" + } + ]; + bool primary = 4; + bool generated = 5; +} + +enum FieldName { + FIELD_NAME_UNSPECIFIED = 0; + FIELD_NAME_ID = 1; + FIELD_NAME_NAME = 2; + FIELD_NAME_CREATION_DATE = 3; +} + +message Query { + oneof query { + option (validate.required) = true; + + IdsQuery id_query = 1; + DomainsQuery domain_query = 2; + } +} + +message IdsQuery { + repeated string ids = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Instance ID"; + example: "[\"4820840938402429\",\"4820840938402422\"]" + } + ]; +} + +message DomainsQuery { + repeated string domains = 1 [ + (validate.rules).repeated = {max_items: 20, items: {string: {min_len: 1, max_len: 100}}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_items: 20; + example: "[\"my-instace.zitadel.cloud\", \"auth.custom.com\"]"; + description: "Return the instances that have the requested domains"; + } + ]; +} +message DomainSearchQuery { + oneof query { + option (validate.required) = true; + + DomainQuery domain_query = 1; + DomainGeneratedQuery generated_query = 2; + DomainPrimaryQuery primary_query = 3; + } +} + +message DomainQuery { + string domain = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"zitadel.com\""; + } + ]; + zitadel.object.v2.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines which text equality method is used"; + } + ]; +} + +message DomainGeneratedQuery { + bool generated = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Generated domains"; + } + ]; +} + +message DomainPrimaryQuery { + bool primary = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Primary domains"; + } + ]; +} + +enum DomainFieldName { + DOMAIN_FIELD_NAME_UNSPECIFIED = 0; + DOMAIN_FIELD_NAME_DOMAIN = 1; + DOMAIN_FIELD_NAME_PRIMARY = 2; + DOMAIN_FIELD_NAME_GENERATED = 3; + DOMAIN_FIELD_NAME_CREATION_DATE = 4; +} + +message TrustedDomain { + string instance_id = 1; + + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + string domain = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.com\"" + } + ]; +} + +message TrustedDomainSearchQuery { + oneof query { + option (validate.required) = true; + + DomainQuery domain_query = 1; + } +} + +enum TrustedDomainFieldName { + TRUSTED_DOMAIN_FIELD_NAME_UNSPECIFIED = 0; + TRUSTED_DOMAIN_FIELD_NAME_DOMAIN = 1; + TRUSTED_DOMAIN_FIELD_NAME_CREATION_DATE = 2; +} diff --git a/proto/zitadel/instance/v2beta/instance_service.proto b/proto/zitadel/instance/v2beta/instance_service.proto new file mode 100644 index 0000000000..0a5de00286 --- /dev/null +++ b/proto/zitadel/instance/v2beta/instance_service.proto @@ -0,0 +1,648 @@ +syntax = "proto3"; + +package zitadel.instance.v2beta; + +import "validate/validate.proto"; +import "zitadel/object/v2/object.proto"; +import "zitadel/instance/v2beta/instance.proto"; +import "zitadel/filter/v2beta/filter.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/empty.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta;instance"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Instance Service"; + version: "2.0-beta"; + description: "This API is intended to manage instances in ZITADEL."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "AGPL-3.0-only", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage instances and their domains. +// The service provides methods to create, update, delete and list instances and their domains. +service InstanceService { + + // Delete Instance + // + // Deletes an instance with the given ID. + // + // Required permissions: + // - `system.instance.delete` + rpc DeleteInstance(DeleteInstanceRequest) returns (DeleteInstanceResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The deleted instance."; + } + }; + }; + + option (google.api.http) = { + delete: "/v2beta/instances/{instance_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.instance.delete" + } + }; + } + + // Get Instance + // + // Returns the instance in the current context. + // + // The instace_id in the input message will be used in the future. + // + // Required permissions: + // - `iam.read` + rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The instance of the context."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/instances/{instance_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.read" + } + }; + } + + // Update Instance + // + // Updates instance in context with the given name. + // + // The instance_id in the input message will be used in the future. + // + // Required permissions: + // - `iam.write` + rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The instance was successfully updated."; + } + }; + }; + + option (google.api.http) = { + put: "/v2beta/instances/{instance_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.write" + } + }; + } + + // List Instances + // + // Lists instances matching the given query. + // The query can be used to filter either by instance ID or domain. + // The request is paginated and returns 100 results by default. + // + // Required permissions: + // - `system.instance.read` + rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The list of instances."; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/instances/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.instance.read" + } + }; + } + + // Add Custom Domain + // + // Adds a custom domain to the instance in context. + // + // The instance_id in the input message will be used in the future + // + // Required permissions: + // - `system.domain.write` + rpc AddCustomDomain(AddCustomDomainRequest) returns (AddCustomDomainResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The added custom domain."; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/instances/{instance_id}/custom-domains" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.domain.write" + } + }; + } + + // Remove Custom Domain + // + // Removes a custom domain from the instance. + // + // The instance_id in the input message will be used in the future. + // + // Required permissions: + // - `system.domain.write` + rpc RemoveCustomDomain(RemoveCustomDomainRequest) returns (RemoveCustomDomainResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The removed custom domain."; + } + }; + }; + + option (google.api.http) = { + delete: "/v2beta/instances/{instance_id}/custom-domains/{domain}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "system.domain.write" + } + }; + } + + // List Custom Domains + // + // Lists custom domains of the instance. + // + // The instance_id in the input message will be used in the future. + // + // Required permissions: + // - `iam.read` + rpc ListCustomDomains(ListCustomDomainsRequest) returns (ListCustomDomainsResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The list of custom domains."; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/instances/{instance_id}/custom-domains/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.read" + } + }; + } + + // Add Trusted Domain + // + // Adds a trusted domain to the instance. + // + // The instance_id in the input message will be used in the future. + // + // Required permissions: + // - `iam.write` + rpc AddTrustedDomain(AddTrustedDomainRequest) returns (AddTrustedDomainResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The added trusted domain."; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/instances/{instance_id}/trusted-domains" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.write" + } + }; + } + + // Remove Trusted Domain + // + // Removes a trusted domain from the instance. + // + // The instance_id in the input message will be used in the future. + // + // Required permissions: + // - `iam.write` + rpc RemoveTrustedDomain(RemoveTrustedDomainRequest) returns (RemoveTrustedDomainResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The removed trusted domain."; + } + }; + }; + + option (google.api.http) = { + delete: "/v2beta/instances/{instance_id}/trusted-domains/{domain}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.write" + } + }; + } + + + // List Trusted Domains + // + // Lists trusted domains of the instance. + // + // The instance_id in the input message will be used in the future. + // + // Required permissions: + // - `iam.read` + rpc ListTrustedDomains(ListTrustedDomainsRequest) returns (ListTrustedDomainsResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The list of trusted domains."; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/instances/{instance_id}/trusted-domains/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.read" + } + }; + } +} + +message DeleteInstanceRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; +} + +message DeleteInstanceResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GetInstanceRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; +} + +message GetInstanceResponse { + zitadel.instance.v2beta.Instance instance = 1; +} + +message UpdateInstanceRequest { + // used only to identify the instance to change. + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + string instance_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + description: "\"name of the instance to update\""; + example: "\"my instance\""; + } + ]; +} + +message UpdateInstanceResponse { + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListInstancesRequest { + // Criterias the client is looking for. + repeated Query queries = 1; + + // Pagination and sorting. + zitadel.filter.v2beta.PaginationRequest pagination = 2; + + // The field the result is sorted by. + optional FieldName sorting_column = 3; +} + +message ListInstancesResponse { + // The list of instances. + repeated Instance instances = 1; + + // Contains the total number of instances matching the query and the applied limit. + zitadel.filter.v2beta.PaginationResponse pagination = 2; +} + +message AddCustomDomainRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 253; + } + ]; +} + +message AddCustomDomainResponse { + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RemoveCustomDomainRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 253; + } + ]; +} + +message RemoveCustomDomainResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListCustomDomainsRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + + // Pagination and sorting. + zitadel.filter.v2beta.PaginationRequest pagination = 2; + + // The field the result is sorted by. + DomainFieldName sorting_column = 3; + + // Criterias the client is looking for. + repeated DomainSearchQuery queries = 4; +} + +message ListCustomDomainsResponse { + repeated Domain domains = 1; + + // Contains the total number of domains matching the query and the applied limit. + zitadel.filter.v2beta.PaginationResponse pagination = 2; +} + +message AddTrustedDomainRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"login.example.com\""; + min_length: 1; + max_length: 253; + } + ]; +} + +message AddTrustedDomainResponse { + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RemoveTrustedDomainRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 253}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"login.example.com\""; + min_length: 1; + max_length: 253; + } + ]; +} + +message RemoveTrustedDomainResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListTrustedDomainsRequest { + string instance_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"222430354126975533\""; + } + ]; + + // Pagination and sorting. + zitadel.filter.v2beta.PaginationRequest pagination = 2; + + // The field the result is sorted by. + TrustedDomainFieldName sorting_column = 3; + + // Criterias the client is looking for. + repeated TrustedDomainSearchQuery queries = 4; +} + +message ListTrustedDomainsResponse { + repeated TrustedDomain trusted_domain = 1; + + // Contains the total number of domains matching the query and the applied limit. + zitadel.filter.v2beta.PaginationResponse pagination = 2; +} diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index f124c37a79..09b5559fb9 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -117,6 +117,8 @@ service SystemService { } // Returns a list of ZITADEL instances + // + // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/instance-service-list-instances.api.mdx) instead to list instances rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { option (google.api.http) = { post: "/instances/_search" @@ -126,9 +128,15 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.instance.read"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Returns the detail of an instance + // + // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/instance-service-get-instance.api.mdx) instead to get the details of the instance in context rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { option (google.api.http) = { get: "/instances/{instance_id}"; @@ -137,6 +145,10 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.instance.read"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Deprecated: Use CreateInstance instead @@ -151,9 +163,15 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.instance.write"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Updates name of an existing instance + // + // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/instance-service-update-instance.api.mdx) instead to update the name of the instance in context rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { option (google.api.http) = { put: "/instances/{instance_id}" @@ -163,6 +181,10 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.instance.write"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Creates a new instance with all needed setup data @@ -180,6 +202,8 @@ service SystemService { // Removes an instance // This might take some time + // + // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/instance-service-delete-instance.api.mdx) instead to delete an instance rpc RemoveInstance(RemoveInstanceRequest) returns (RemoveInstanceResponse) { option (google.api.http) = { delete: "/instances/{instance_id}" @@ -188,6 +212,10 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.instance.delete"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } //Returns all instance members matching the request @@ -204,7 +232,9 @@ service SystemService { }; } - //Checks if a domain exists + // Checks if a domain exists + // + // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/instance-service-list-custom-domains.api.mdx) instead to check existence of an instance rpc ExistsDomain(ExistsDomainRequest) returns (ExistsDomainResponse) { option (google.api.http) = { post: "/domains/{domain}/_exists"; @@ -214,10 +244,14 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.domain.read"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Returns the custom domains of an instance - //Checks if a domain exists + // Checks if a domain exists // Deprecated: Use the Admin APIs ListInstanceDomains on the admin API instead rpc ListDomains(ListDomainsRequest) returns (ListDomainsResponse) { option (google.api.http) = { @@ -228,9 +262,15 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.domain.read"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Adds a domain to an instance + // + // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context rpc AddDomain(AddDomainRequest) returns (AddDomainResponse) { option (google.api.http) = { post: "/instances/{instance_id}/domains"; @@ -240,9 +280,15 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.domain.write"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Removes the domain of an instance + // + // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context rpc RemoveDomain(RemoveDomainRequest) returns (RemoveDomainResponse) { option (google.api.http) = { delete: "/instances/{instance_id}/domains/{domain}"; @@ -251,6 +297,10 @@ service SystemService { option (zitadel.v1.auth_option) = { permission: "system.domain.delete"; }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + }; } // Sets the primary domain of an instance From 6889d6a1daba1ce6e9696f43a6d4c0fe1b3cb400 Mon Sep 17 00:00:00 2001 From: alfa-alex <76205862+alfa-alex@users.noreply.github.com> Date: Wed, 21 May 2025 12:55:40 +0200 Subject: [PATCH 062/123] feat: add custom org ID to AddOrganizationRequest (#9720) # Which Problems Are Solved - It is not possible to specify a custom organization ID when creating an organization. According to https://github.com/zitadel/zitadel/discussions/9202#discussioncomment-11929464 this is "an inconsistency in the V2 API". # How the Problems Are Solved - Adds the `org_id` as an optional parameter to the `AddOrganizationRequest` in the `v2beta` API. # Additional Changes None. # Additional Context - Discussion [#9202](https://github.com/zitadel/zitadel/discussions/9202) - I was mostly interested in how much work it'd be to add this field. Then after completing this, I thought I'd submit this PR. I won't be angry if you just close this PR with the reasoning "we didn't ask for it". :smile: - Even though I don't think this is a breaking change, I didn't add this to the `v2` API yet (don't know what the process for this is TBH). The changes should be analogous, so if you want me to, just request it. --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- .../grpc/org/v2/integration_test/org_test.go | 12 ++++ .../org/v2/integration_test/query_test.go | 39 +++++++++++++ internal/api/grpc/org/v2/org.go | 1 + internal/api/grpc/org/v2/org_test.go | 15 +++++ .../org/v2beta/integration_test/org_test.go | 12 ++++ internal/api/grpc/org/v2beta/org.go | 1 + internal/api/grpc/org/v2beta/org_test.go | 15 +++++ internal/command/org.go | 21 ++++++- internal/command/org_test.go | 55 +++++++++++++++++++ internal/integration/client.go | 9 +++ proto/zitadel/org/v2/org_service.proto | 8 +++ proto/zitadel/org/v2beta/org_service.proto | 8 +++ 12 files changed, 193 insertions(+), 3 deletions(-) diff --git a/internal/api/grpc/org/v2/integration_test/org_test.go b/internal/api/grpc/org/v2/integration_test/org_test.go index aa8a718e68..b28bbf5ef2 100644 --- a/internal/api/grpc/org/v2/integration_test/org_test.go +++ b/internal/api/grpc/org/v2/integration_test/org_test.go @@ -81,6 +81,18 @@ func TestServer_AddOrganization(t *testing.T) { }, wantErr: true, }, + { + name: "no admin, custom org ID", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: gofakeit.AppName(), + OrgId: gu.Ptr("custom-org-ID"), + }, + want: &org.AddOrganizationResponse{ + OrganizationId: "custom-org-ID", + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{}, + }, + }, { name: "admin with init with userID passed for Human admin", ctx: CTX, diff --git a/internal/api/grpc/org/v2/integration_test/query_test.go b/internal/api/grpc/org/v2/integration_test/query_test.go index cb7576455c..b2f27dbe62 100644 --- a/internal/api/grpc/org/v2/integration_test/query_test.go +++ b/internal/api/grpc/org/v2/integration_test/query_test.go @@ -38,6 +38,16 @@ func createOrganization(ctx context.Context, name string) orgAttr { } } +func createOrganizationWithCustomOrgID(ctx context.Context, name string, orgID string) orgAttr { + orgResp := Instance.CreateOrganizationWithCustomOrgID(ctx, name, orgID) + orgResp.Details.CreationDate = orgResp.Details.ChangeDate + return orgAttr{ + ID: orgResp.GetOrganizationId(), + Name: name, + Details: orgResp.GetDetails(), + } +} + func TestServer_ListOrganizations(t *testing.T) { type args struct { ctx context.Context @@ -163,6 +173,35 @@ func TestServer_ListOrganizations(t *testing.T) { }, }, }, + { + name: "list org by custom id, ok", + args: args{ + CTX, + &org.ListOrganizationsRequest{}, + func(ctx context.Context, request *org.ListOrganizationsRequest) ([]orgAttr, error) { + orgs := make([]orgAttr, 1) + name := fmt.Sprintf("ListOrgs-%s", gofakeit.AppName()) + orgID := gofakeit.Company() + orgs[0] = createOrganizationWithCustomOrgID(ctx, name, orgID) + request.Queries = []*org.SearchQuery{ + OrganizationIdQuery(orgID), + } + return orgs, nil + }, + }, + want: &org.ListOrganizationsResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*org.Organization{ + { + State: org.OrganizationState_ORGANIZATION_STATE_ACTIVE, + }, + }, + }, + }, { name: "list org by name, ok", args: args{ diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go index 5f21f7403e..bbc3caca85 100644 --- a/internal/api/grpc/org/v2/org.go +++ b/internal/api/grpc/org/v2/org.go @@ -31,6 +31,7 @@ func addOrganizationRequestToCommand(request *org.AddOrganizationRequest) (*comm Name: request.GetName(), CustomDomain: "", Admins: admins, + OrgID: request.GetOrgId(), }, nil } diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go index b384f858de..7ae252a209 100644 --- a/internal/api/grpc/org/v2/org_test.go +++ b/internal/api/grpc/org/v2/org_test.go @@ -38,6 +38,21 @@ func Test_addOrganizationRequestToCommand(t *testing.T) { }, wantErr: zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", nil), }, + { + name: "custom org ID", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "custom org ID", + OrgId: gu.Ptr("org-ID"), + }, + }, + want: &command.OrgSetup{ + Name: "custom org ID", + CustomDomain: "", + Admins: []*command.OrgSetupAdmin{}, + OrgID: "org-ID", + }, + }, { name: "user ID", args: args{ diff --git a/internal/api/grpc/org/v2beta/integration_test/org_test.go b/internal/api/grpc/org/v2beta/integration_test/org_test.go index 5998b17a71..a2b2bf6047 100644 --- a/internal/api/grpc/org/v2beta/integration_test/org_test.go +++ b/internal/api/grpc/org/v2beta/integration_test/org_test.go @@ -79,6 +79,18 @@ func TestServer_AddOrganization(t *testing.T) { }, wantErr: true, }, + { + name: "no admin, custom org ID", + ctx: CTX, + req: &org.AddOrganizationRequest{ + Name: gofakeit.AppName(), + OrgId: gu.Ptr("custom-org-ID"), + }, + want: &org.AddOrganizationResponse{ + OrganizationId: "custom-org-ID", + CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{}, + }, + }, { name: "admin with init", ctx: CTX, diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go index ab2da2b766..39730f827e 100644 --- a/internal/api/grpc/org/v2beta/org.go +++ b/internal/api/grpc/org/v2beta/org.go @@ -31,6 +31,7 @@ func addOrganizationRequestToCommand(request *org.AddOrganizationRequest) (*comm Name: request.GetName(), CustomDomain: "", Admins: admins, + OrgID: request.GetOrgId(), }, nil } diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go index 5024b59c1d..57ed05dfb2 100644 --- a/internal/api/grpc/org/v2beta/org_test.go +++ b/internal/api/grpc/org/v2beta/org_test.go @@ -39,6 +39,21 @@ func Test_addOrganizationRequestToCommand(t *testing.T) { }, wantErr: zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", nil), }, + { + name: "custom org ID", + args: args{ + request: &org.AddOrganizationRequest{ + Name: "custom org ID", + OrgId: gu.Ptr("org-ID"), + }, + }, + want: &command.OrgSetup{ + Name: "custom org ID", + CustomDomain: "", + Admins: []*command.OrgSetupAdmin{}, + OrgID: "org-ID", + }, + }, { name: "user ID", args: args{ diff --git a/internal/command/org.go b/internal/command/org.go index a018a90c82..9874410a5f 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -31,6 +31,7 @@ type OrgSetup struct { Name string CustomDomain string Admins []*OrgSetupAdmin + OrgID string } // OrgSetupAdmin describes a user to be created (Human / Machine) or an existing (ID) to be used for an org setup. @@ -64,6 +65,13 @@ type CreatedOrgAdmin struct { MachineKey *MachineKey } +func (o *OrgSetup) Validate() (err error) { + if o.OrgID != "" && strings.TrimSpace(o.OrgID) == "" { + return zerrors.ThrowInvalidArgument(nil, "ORG-4ABd3", "Errors.Invalid.Argument") + } + return nil +} + func (c *Commands) setUpOrgWithIDs(ctx context.Context, o *OrgSetup, orgID string, allowInitialMail bool, userIDs ...string) (_ *CreatedOrg, err error) { cmds := c.newOrgSetupCommands(ctx, orgID, o) for _, admin := range o.Admins { @@ -233,12 +241,19 @@ func (c *orgSetupCommands) createdMachineAdmin(admin *OrgSetupAdmin) *CreatedOrg } func (c *Commands) SetUpOrg(ctx context.Context, o *OrgSetup, allowInitialMail bool, userIDs ...string) (*CreatedOrg, error) { - orgID, err := c.idGenerator.Next() - if err != nil { + if err := o.Validate(); err != nil { return nil, err } - return c.setUpOrgWithIDs(ctx, o, orgID, allowInitialMail, userIDs...) + if o.OrgID == "" { + var err error + o.OrgID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } + } + + return c.setUpOrgWithIDs(ctx, o, o.OrgID, allowInitialMail, userIDs...) } // AddOrgCommand defines the commands to create a new org, diff --git a/internal/command/org_test.go b/internal/command/org_test.go index 4ec85d61e1..4b6fd7afe5 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -1344,6 +1344,22 @@ func TestCommandSide_SetUpOrg(t *testing.T) { err: zerrors.ThrowInvalidArgument(nil, "ORG-mruNY", "Errors.Invalid.Argument"), }, }, + { + name: "org id empty, error", + fields: fields{ + eventstore: expectEventstore(), + }, + args: args{ + ctx: http_util.WithRequestedHost(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "Org", + OrgID: " ", + }, + }, + res: res{ + err: zerrors.ThrowInvalidArgument(nil, "ORG-4ABd3", "Errors.Invalid.Argument"), + }, + }, { name: "userID not existing, error", fields: fields{ @@ -1523,6 +1539,45 @@ func TestCommandSide_SetUpOrg(t *testing.T) { }, }, }, + { + name: "no human added, custom org ID", + fields: fields{ + eventstore: expectEventstore( + expectPush( + eventFromEventPusher(org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("custom-org-ID").Aggregate, + "Org", + )), + eventFromEventPusher(org.NewDomainAddedEvent(context.Background(), + &org.NewAggregate("custom-org-ID").Aggregate, "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainVerifiedEvent(context.Background(), + &org.NewAggregate("custom-org-ID").Aggregate, + "org.iam-domain", + )), + eventFromEventPusher(org.NewDomainPrimarySetEvent(context.Background(), + &org.NewAggregate("custom-org-ID").Aggregate, + "org.iam-domain", + )), + ), + ), + }, + args: args{ + ctx: http_util.WithRequestedHost(context.Background(), "iam-domain"), + setupOrg: &OrgSetup{ + Name: "Org", + OrgID: "custom-org-ID", + }, + }, + res: res{ + createdOrg: &CreatedOrg{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "custom-org-ID", + }, + CreatedAdmins: []*CreatedOrgAdmin{}, + }, + }, + }, { name: "existing human added", fields: fields{ diff --git a/internal/integration/client.go b/internal/integration/client.go index bd9775a28a..61645cc067 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -293,6 +293,15 @@ func SetOrgID(ctx context.Context, orgID string) context.Context { return metadata.NewOutgoingContext(ctx, md) } +func (i *Instance) CreateOrganizationWithCustomOrgID(ctx context.Context, name, orgID string) *org.AddOrganizationResponse { + resp, err := i.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ + Name: name, + OrgId: gu.Ptr(orgID), + }) + logging.OnError(err).Fatal("create org") + return resp +} + func (i *Instance) CreateOrganizationWithUserID(ctx context.Context, name, userID string) *org.AddOrganizationResponse { resp, err := i.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ Name: name, diff --git a/proto/zitadel/org/v2/org_service.proto b/proto/zitadel/org/v2/org_service.proto index 94ced55146..729350e1f9 100644 --- a/proto/zitadel/org/v2/org_service.proto +++ b/proto/zitadel/org/v2/org_service.proto @@ -197,6 +197,14 @@ message AddOrganizationRequest{ } ]; repeated Admin admins = 2; + // optionally set your own id unique for the organization. + optional string org_id = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; } message AddOrganizationResponse{ diff --git a/proto/zitadel/org/v2beta/org_service.proto b/proto/zitadel/org/v2beta/org_service.proto index 90c29ca354..e303b676d7 100644 --- a/proto/zitadel/org/v2beta/org_service.proto +++ b/proto/zitadel/org/v2beta/org_service.proto @@ -160,6 +160,14 @@ message AddOrganizationRequest{ } ]; repeated Admin admins = 2; + // optionally set your own id unique for the organization. + optional string org_id = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; } message AddOrganizationResponse{ From 7eb45c6cfd8092d99bd90a318e50a1fbcb017257 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 21 May 2025 14:40:47 +0200 Subject: [PATCH 063/123] feat: project v2beta resource API (#9742) # Which Problems Are Solved Resource management of projects and sub-resources was before limited by the context provided by the management API, which would mean you could only manage resources belonging to a specific organization. # How the Problems Are Solved With the addition of a resource-based API, it is now possible to manage projects and sub-resources on the basis of the resources themselves, which means that as long as you have the permission for the resource, you can create, read, update and delete it. - CreateProject to create a project under an organization - UpdateProject to update an existing project - DeleteProject to delete an existing project - DeactivateProject and ActivateProject to change the status of a project - GetProject to query for a specific project with an identifier - ListProject to query for projects and granted projects - CreateProjectGrant to create a project grant with project and granted organization - UpdateProjectGrant to update the roles of a project grant - DeactivateProjectGrant and ActivateProjectGrant to change the status of a project grant - DeleteProjectGrant to delete an existing project grant - ListProjectGrants to query for project grants - AddProjectRole to add a role to an existing project - UpdateProjectRole to change texts of an existing role - RemoveProjectRole to remove an existing role - ListProjectRoles to query for project roles # Additional Changes - Changes to ListProjects, which now contains granted projects as well - Changes to messages as defined in the [API_DESIGN](https://github.com/zitadel/zitadel/blob/main/API_DESIGN.md) - Permission checks for project functionality on query and command side - Added testing to unit tests on command side - Change update endpoints to no error returns if nothing changes in the resource - Changed all integration test utility to the new service - ListProjects now also correctly lists `granted projects` - Permission checks for project grant and project role functionality on query and command side - Change existing pre checks so that they also work resource specific without resourceowner - Added the resourceowner to the grant and role if no resourceowner is provided - Corrected import tests with project grants and roles - Added testing to unit tests on command side - Change update endpoints to no error returns if nothing changes in the resource - Changed all integration test utility to the new service - Corrected some naming in the proto files to adhere to the API_DESIGN # Additional Context Closes #9177 --------- Co-authored-by: Livio Spring --- cmd/start/start.go | 4 + docs/docusaurus.config.js | 8 + docs/sidebars.js | 12 + .../integration_test/execution_target_test.go | 9 +- internal/api/grpc/admin/export.go | 6 +- internal/api/grpc/admin/import.go | 6 +- .../admin/integration_test/import_test.go | 24 + internal/api/grpc/management/project.go | 27 +- .../api/grpc/management/project_converter.go | 51 +- internal/api/grpc/management/project_grant.go | 17 +- .../management/project_grant_converter.go | 16 +- .../oidc/v2/integration_test/oidc_test.go | 35 +- .../oidc/v2beta/integration_test/oidc_test.go | 29 +- .../v2beta/integration/project_grant_test.go | 1292 ++++++++++++ .../v2beta/integration/project_role_test.go | 698 +++++++ .../v2beta/integration/project_test.go | 1013 ++++++++++ .../project/v2beta/integration/query_test.go | 1738 +++++++++++++++++ .../project/v2beta/integration/server_test.go | 63 + internal/api/grpc/project/v2beta/project.go | 161 ++ .../api/grpc/project/v2beta/project_grant.go | 126 ++ .../api/grpc/project/v2beta/project_role.go | 132 ++ internal/api/grpc/project/v2beta/query.go | 427 ++++ internal/api/grpc/project/v2beta/server.go | 60 + .../api/grpc/saml/v2/integration/saml_test.go | 21 +- .../user/v2/integration_test/user_test.go | 4 +- .../user/v2beta/integration_test/user_test.go | 21 +- internal/api/oidc/access_token.go | 2 +- internal/api/oidc/auth_request.go | 6 +- .../api/oidc/integration_test/client_test.go | 10 +- .../api/oidc/integration_test/oidc_test.go | 7 +- .../integration_test/token_device_test.go | 4 +- .../integration_test/token_exchange_test.go | 6 +- .../oidc/integration_test/userinfo_test.go | 4 +- .../integration_test/users_delete_test.go | 6 +- .../eventsourcing/eventstore/auth_request.go | 4 +- .../eventstore/auth_request_test.go | 2 +- internal/command/org.go | 2 +- internal/command/permission_checks.go | 13 + internal/command/project.go | 238 ++- internal/command/project_application_api.go | 4 +- internal/command/project_application_oidc.go | 4 +- internal/command/project_application_saml.go | 2 +- internal/command/project_grant.go | 276 ++- internal/command/project_grant_model.go | 8 +- internal/command/project_grant_test.go | 505 +++-- internal/command/project_model.go | 41 +- internal/command/project_old.go | 30 +- internal/command/project_role.go | 138 +- internal/command/project_role_model.go | 4 + internal/command/project_role_test.go | 247 ++- internal/command/project_test.go | 845 ++++++-- internal/domain/permission.go | 9 + internal/domain/project_grant.go | 13 +- internal/domain/project_role.go | 13 +- internal/integration/client.go | 105 +- internal/integration/oidc.go | 34 +- internal/query/project.go | 372 +++- internal/query/project_grant.go | 88 +- internal/query/project_role.go | 43 +- internal/repository/project/aggregate.go | 6 + internal/repository/project/project.go | 7 +- proto/zitadel/management.proto | 176 +- .../project/v2beta/project_service.proto | 1237 ++++++++++++ proto/zitadel/project/v2beta/query.proto | 347 ++++ 64 files changed, 9821 insertions(+), 1037 deletions(-) create mode 100644 internal/api/grpc/project/v2beta/integration/project_grant_test.go create mode 100644 internal/api/grpc/project/v2beta/integration/project_role_test.go create mode 100644 internal/api/grpc/project/v2beta/integration/project_test.go create mode 100644 internal/api/grpc/project/v2beta/integration/query_test.go create mode 100644 internal/api/grpc/project/v2beta/integration/server_test.go create mode 100644 internal/api/grpc/project/v2beta/project.go create mode 100644 internal/api/grpc/project/v2beta/project_grant.go create mode 100644 internal/api/grpc/project/v2beta/project_role.go create mode 100644 internal/api/grpc/project/v2beta/query.go create mode 100644 internal/api/grpc/project/v2beta/server.go create mode 100644 proto/zitadel/project/v2beta/project_service.proto create mode 100644 proto/zitadel/project/v2beta/query.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 6f04e8ee82..1d83197062 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -46,6 +46,7 @@ import ( 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" + project_v2beta "github.com/zitadel/zitadel/internal/api/grpc/project/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/resources/debug_events/debug_events" user_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/user/v3alpha" userschema_v3_alpha "github.com/zitadel/zitadel/internal/api/grpc/resources/userschema/v3alpha" @@ -491,6 +492,9 @@ func startAPIs( if err := apis.RegisterService(ctx, action_v2_beta.CreateServer(config.SystemDefaults, commands, queries, domain.AllActionFunctions, apis.ListGrpcMethods, apis.ListGrpcServices)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, project_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, userschema_v3_alpha.CreateServer(config.SystemDefaults, commands, queries)); err != nil { return nil, err } diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 6a4429cffe..22df468475 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -364,6 +364,14 @@ module.exports = { categoryLinkSource: "auto", }, }, + project_v2beta: { + specPath: ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", + outputDir: "docs/apis/resources/project_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, instance_v2: { specPath: ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", outputDir: "docs/apis/resources/instance_service_v2", diff --git a/docs/sidebars.js b/docs/sidebars.js index b7dc3fd8b8..b7a399ecf1 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -12,6 +12,7 @@ const sidebar_api_feature_service_v2 = require("./docs/apis/resources/feature_se const sidebar_api_org_service_v2 = require("./docs/apis/resources/org_service_v2/sidebar.ts").default const sidebar_api_idp_service_v2 = require("./docs/apis/resources/idp_service_v2/sidebar.ts").default const sidebar_api_actions_v2 = require("./docs/apis/resources/action_service_v2/sidebar.ts").default +const sidebar_api_project_service_v2 = require("./docs/apis/resources/project_service_v2/sidebar.ts").default const sidebar_api_webkey_service_v2 = require("./docs/apis/resources/webkey_service_v2/sidebar.ts").default const sidebar_api_instance_service_v2 = require("./docs/apis/resources/instance_service_v2/sidebar.ts").default @@ -843,6 +844,17 @@ module.exports = { }, { type: "category", + label: "Project (Beta)", + link: { + type: "generated-index", + title: "Project Service API (Beta)", + slug: "/apis/resources/project_service_v2", + description: + "This API is intended to manage projects and subresources for ZITADEL. \n"+ + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.", + }, + items: sidebar_api_project_service_v2, label: "Instance (Beta)", link: { type: "generated-index", diff --git a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go index 0c5018dbb6..9fa568fb8b 100644 --- a/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/execution_target_test.go @@ -580,7 +580,7 @@ func TestServer_ExecutionTargetPreUserinfo(t *testing.T) { isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) - client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, redirectURIImplicit, loginV2) + client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, t, redirectURIImplicit, loginV2) require.NoError(t, err) type want struct { @@ -893,7 +893,7 @@ func TestServer_ExecutionTargetPreAccessToken(t *testing.T) { isolatedIAMCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) ctxLoginClient := instance.WithAuthorization(CTX, integration.UserTypeLogin) - client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, redirectURIImplicit, loginV2) + client, err := instance.CreateOIDCImplicitFlowClient(isolatedIAMCtx, t, redirectURIImplicit, loginV2) require.NoError(t, err) type want struct { @@ -1255,10 +1255,9 @@ func createSAMLSP(t *testing.T, idpMetadata *saml.EntityDescriptor, binding stri } func createSAMLApplication(ctx context.Context, t *testing.T, instance *integration.Instance, idpMetadata *saml.EntityDescriptor, binding string, projectRoleCheck, hasProjectCheck bool) (string, string, *samlsp.Middleware) { - project, err := instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) - require.NoError(t, err) + project := instance.CreateProject(ctx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), projectRoleCheck, hasProjectCheck) rootURL, sp := createSAMLSP(t, idpMetadata, binding) - _, err = instance.CreateSAMLClient(ctx, project.GetId(), sp) + _, err := instance.CreateSAMLClient(ctx, project.GetId(), sp) require.NoError(t, err) return project.GetId(), rootURL, sp } diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index 68b6053c2c..2558e5b5fc 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -736,7 +736,7 @@ func (s *Server) getProjectsAndApps(ctx context.Context, org string) ([]*v1_pb.D if err != nil { return nil, nil, nil, nil, nil, err } - queriedProjects, err := s.query.SearchProjects(ctx, &query.ProjectSearchQueries{Queries: []query.SearchQuery{projectSearch}}) + queriedProjects, err := s.query.SearchProjects(ctx, &query.ProjectSearchQueries{Queries: []query.SearchQuery{projectSearch}}, nil) if err != nil { return nil, nil, nil, nil, nil, err } @@ -763,7 +763,7 @@ func (s *Server) getProjectsAndApps(ctx context.Context, org string) ([]*v1_pb.D return nil, nil, nil, nil, nil, err } - queriedProjectRoles, err := s.query.SearchProjectRoles(ctx, false, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectRoleSearch}}) + queriedProjectRoles, err := s.query.SearchProjectRoles(ctx, false, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectRoleSearch}}, nil) if err != nil { return nil, nil, nil, nil, nil, err } @@ -945,7 +945,7 @@ func (s *Server) getNecessaryProjectGrantsForOrg(ctx context.Context, org string if err != nil { return nil, err } - queriedProjectGrants, err := s.query.SearchProjectGrants(ctx, &query.ProjectGrantSearchQueries{Queries: []query.SearchQuery{projectGrantSearchOrg}}) + queriedProjectGrants, err := s.query.SearchProjectGrants(ctx, &query.ProjectGrantSearchQueries{Queries: []query.SearchQuery{projectGrantSearchOrg}}, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 5bbcab27cf..119afe9fc0 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -621,7 +621,7 @@ func importProjects(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDa } for _, project := range org.GetProjects() { logging.Debugf("import project: %s", project.GetProjectId()) - _, err := s.command.AddProjectWithID(ctx, management.ProjectCreateToDomain(project.GetProject()), org.GetOrgId(), project.GetProjectId()) + _, err := s.command.AddProject(ctx, management.ProjectCreateToCommand(project.GetProject(), project.GetProjectId(), org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "project", Id: project.GetProjectId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -761,7 +761,7 @@ func importProjectRoles(ctx context.Context, s *Server, errors *[]*admin_pb.Impo logging.Debugf("import projectroles: %s", role.ProjectId+"_"+role.RoleKey) // TBD: why not command.BulkAddProjectRole? - _, err := s.command.AddProjectRole(ctx, management.AddProjectRoleRequestToDomain(role), org.GetOrgId()) + _, err := s.command.AddProjectRole(ctx, management.AddProjectRoleRequestToCommand(role, org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "project_role", Id: role.ProjectId + "_" + role.RoleKey, Message: err.Error()}) if isCtxTimeout(ctx) { @@ -1023,7 +1023,7 @@ func importOrg2(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataEr if org.ProjectGrants != nil { for _, grant := range org.GetProjectGrants() { logging.Debugf("import projectgrant: %s", grant.GetGrantId()+"_"+grant.GetProjectGrant().GetProjectId()+"_"+grant.GetProjectGrant().GetGrantedOrgId()) - _, err := s.command.AddProjectGrantWithID(ctx, management.AddProjectGrantRequestToDomain(grant.GetProjectGrant()), grant.GetGrantId(), org.GetOrgId()) + _, err := s.command.AddProjectGrant(ctx, management.AddProjectGrantRequestToCommand(grant.GetProjectGrant(), grant.GetGrantId(), org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "project_grant", Id: org.GetOrgId() + "_" + grant.GetProjectGrant().GetProjectId() + "_" + grant.GetProjectGrant().GetGrantedOrgId(), Message: err.Error()}) if isCtxTimeout(ctx) { diff --git a/internal/api/grpc/admin/integration_test/import_test.go b/internal/api/grpc/admin/integration_test/import_test.go index 7d323e5ab8..4a546bbcf1 100644 --- a/internal/api/grpc/admin/integration_test/import_test.go +++ b/internal/api/grpc/admin/integration_test/import_test.go @@ -259,6 +259,12 @@ func TestServer_ImportData(t *testing.T) { Data: &admin.ImportDataRequest_DataOrgs{ DataOrgs: &admin.ImportDataOrg{ Orgs: []*admin.DataOrg{ + { + OrgId: orgIDs[4], + Org: &management.AddOrgRequest{ + Name: gofakeit.ProductName(), + }, + }, { OrgId: orgIDs[3], Org: &management.AddOrgRequest{ @@ -336,6 +342,9 @@ func TestServer_ImportData(t *testing.T) { }, Success: &admin.ImportDataSuccess{ Orgs: []*admin.ImportDataSuccessOrg{ + { + OrgId: orgIDs[4], + }, { OrgId: orgIDs[3], ProjectIds: projectIDs[2:4], @@ -363,6 +372,12 @@ func TestServer_ImportData(t *testing.T) { Data: &admin.ImportDataRequest_DataOrgs{ DataOrgs: &admin.ImportDataOrg{ Orgs: []*admin.DataOrg{ + { + OrgId: orgIDs[6], + Org: &management.AddOrgRequest{ + Name: gofakeit.ProductName(), + }, + }, { OrgId: orgIDs[5], Org: &management.AddOrgRequest{ @@ -383,6 +398,11 @@ func TestServer_ImportData(t *testing.T) { RoleKey: "role1", DisplayName: "role1", }, + { + ProjectId: projectIDs[4], + RoleKey: "role2", + DisplayName: "role2", + }, }, HumanUsers: []*v1.DataHumanUser{ { @@ -442,11 +462,15 @@ func TestServer_ImportData(t *testing.T) { }, Success: &admin.ImportDataSuccess{ Orgs: []*admin.ImportDataSuccessOrg{ + { + OrgId: orgIDs[6], + }, { OrgId: orgIDs[5], ProjectIds: projectIDs[4:5], ProjectRoles: []string{ projectIDs[4] + "_role1", + projectIDs[4] + "_role2", }, HumanUserIds: userIDs[2:3], ProjectGrants: []*admin.ImportDataSuccessProjectGrant{ diff --git a/internal/api/grpc/management/project.go b/internal/api/grpc/management/project.go index 52b6b10e9a..f3af8dbf86 100644 --- a/internal/api/grpc/management/project.go +++ b/internal/api/grpc/management/project.go @@ -47,7 +47,7 @@ func (s *Server) ListProjects(ctx context.Context, req *mgmt_pb.ListProjectsRequ if err != nil { return nil, err } - projects, err := s.query.SearchProjects(ctx, queries) + projects, err := s.query.SearchProjects(ctx, queries, nil) if err != nil { return nil, err } @@ -109,7 +109,7 @@ func (s *Server) ListGrantedProjects(ctx context.Context, req *mgmt_pb.ListGrant if err != nil { return nil, err } - projects, err := s.query.SearchProjectGrants(ctx, queries) + projects, err := s.query.SearchProjectGrants(ctx, queries, nil) if err != nil { return nil, err } @@ -175,25 +175,26 @@ func (s *Server) ListProjectChanges(ctx context.Context, req *mgmt_pb.ListProjec } func (s *Server) AddProject(ctx context.Context, req *mgmt_pb.AddProjectRequest) (*mgmt_pb.AddProjectResponse, error) { - project, err := s.command.AddProject(ctx, ProjectCreateToDomain(req), authz.GetCtxData(ctx).OrgID) + add := ProjectCreateToCommand(req, "", authz.GetCtxData(ctx).OrgID) + project, err := s.command.AddProject(ctx, add) if err != nil { return nil, err } return &mgmt_pb.AddProjectResponse{ - Id: project.AggregateID, - Details: object_grpc.AddToDetailsPb(project.Sequence, project.ChangeDate, project.ResourceOwner), + Id: add.AggregateID, + Details: object_grpc.AddToDetailsPb(project.Sequence, project.EventDate, project.ResourceOwner), }, nil } func (s *Server) UpdateProject(ctx context.Context, req *mgmt_pb.UpdateProjectRequest) (*mgmt_pb.UpdateProjectResponse, error) { - project, err := s.command.ChangeProject(ctx, ProjectUpdateToDomain(req), authz.GetCtxData(ctx).OrgID) + project, err := s.command.ChangeProject(ctx, ProjectUpdateToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.UpdateProjectResponse{ Details: object_grpc.ChangeToDetailsPb( project.Sequence, - project.ChangeDate, + project.EventDate, project.ResourceOwner, ), }, nil @@ -252,7 +253,7 @@ func (s *Server) ListProjectRoles(ctx context.Context, req *mgmt_pb.ListProjectR if err != nil { return nil, err } - roles, err := s.query.SearchProjectRoles(ctx, true, queries) + roles, err := s.query.SearchProjectRoles(ctx, true, queries, nil) if err != nil { return nil, err } @@ -263,21 +264,21 @@ func (s *Server) ListProjectRoles(ctx context.Context, req *mgmt_pb.ListProjectR } func (s *Server) AddProjectRole(ctx context.Context, req *mgmt_pb.AddProjectRoleRequest) (*mgmt_pb.AddProjectRoleResponse, error) { - role, err := s.command.AddProjectRole(ctx, AddProjectRoleRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + role, err := s.command.AddProjectRole(ctx, AddProjectRoleRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.AddProjectRoleResponse{ Details: object_grpc.AddToDetailsPb( role.Sequence, - role.ChangeDate, + role.EventDate, role.ResourceOwner, ), }, nil } func (s *Server) BulkAddProjectRoles(ctx context.Context, req *mgmt_pb.BulkAddProjectRolesRequest) (*mgmt_pb.BulkAddProjectRolesResponse, error) { - details, err := s.command.BulkAddProjectRole(ctx, req.ProjectId, authz.GetCtxData(ctx).OrgID, BulkAddProjectRolesRequestToDomain(req)) + details, err := s.command.BulkAddProjectRole(ctx, req.ProjectId, authz.GetCtxData(ctx).OrgID, BulkAddProjectRolesRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } @@ -287,14 +288,14 @@ func (s *Server) BulkAddProjectRoles(ctx context.Context, req *mgmt_pb.BulkAddPr } func (s *Server) UpdateProjectRole(ctx context.Context, req *mgmt_pb.UpdateProjectRoleRequest) (*mgmt_pb.UpdateProjectRoleResponse, error) { - role, err := s.command.ChangeProjectRole(ctx, UpdateProjectRoleRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + role, err := s.command.ChangeProjectRole(ctx, UpdateProjectRoleRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.UpdateProjectRoleResponse{ Details: object_grpc.ChangeToDetailsPb( role.Sequence, - role.ChangeDate, + role.EventDate, role.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/project_converter.go b/internal/api/grpc/management/project_converter.go index 64243ba258..83a8246feb 100644 --- a/internal/api/grpc/management/project_converter.go +++ b/internal/api/grpc/management/project_converter.go @@ -3,10 +3,13 @@ package management import ( "context" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/authz" member_grpc "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" proj_grpc "github.com/zitadel/zitadel/internal/api/grpc/project" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" @@ -14,8 +17,12 @@ import ( proj_pb "github.com/zitadel/zitadel/pkg/grpc/project" ) -func ProjectCreateToDomain(req *mgmt_pb.AddProjectRequest) *domain.Project { - return &domain.Project{ +func ProjectCreateToCommand(req *mgmt_pb.AddProjectRequest, projectID string, resourceOwner string) *command.AddProject { + return &command.AddProject{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + ResourceOwner: resourceOwner, + }, Name: req.Name, ProjectRoleAssertion: req.ProjectRoleAssertion, ProjectRoleCheck: req.ProjectRoleCheck, @@ -24,16 +31,17 @@ func ProjectCreateToDomain(req *mgmt_pb.AddProjectRequest) *domain.Project { } } -func ProjectUpdateToDomain(req *mgmt_pb.UpdateProjectRequest) *domain.Project { - return &domain.Project{ +func ProjectUpdateToCommand(req *mgmt_pb.UpdateProjectRequest, resourceOwner string) *command.ChangeProject { + return &command.ChangeProject{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.Id, + AggregateID: req.Id, + ResourceOwner: resourceOwner, }, - Name: req.Name, - ProjectRoleAssertion: req.ProjectRoleAssertion, - ProjectRoleCheck: req.ProjectRoleCheck, - HasProjectCheck: req.HasProjectCheck, - PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting), + Name: gu.Ptr(req.Name), + ProjectRoleAssertion: gu.Ptr(req.ProjectRoleAssertion), + ProjectRoleCheck: gu.Ptr(req.ProjectRoleCheck), + HasProjectCheck: gu.Ptr(req.HasProjectCheck), + PrivateLabelingSetting: gu.Ptr(privateLabelingSettingToDomain(req.PrivateLabelingSetting)), } } @@ -48,10 +56,11 @@ func privateLabelingSettingToDomain(setting proj_pb.PrivateLabelingSetting) doma } } -func AddProjectRoleRequestToDomain(req *mgmt_pb.AddProjectRoleRequest) *domain.ProjectRole { - return &domain.ProjectRole{ +func AddProjectRoleRequestToCommand(req *mgmt_pb.AddProjectRoleRequest, resourceOwner string) *command.AddProjectRole { + return &command.AddProjectRole{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, + AggregateID: req.ProjectId, + ResourceOwner: resourceOwner, }, Key: req.RoleKey, DisplayName: req.DisplayName, @@ -59,12 +68,13 @@ func AddProjectRoleRequestToDomain(req *mgmt_pb.AddProjectRoleRequest) *domain.P } } -func BulkAddProjectRolesRequestToDomain(req *mgmt_pb.BulkAddProjectRolesRequest) []*domain.ProjectRole { - roles := make([]*domain.ProjectRole, len(req.Roles)) +func BulkAddProjectRolesRequestToCommand(req *mgmt_pb.BulkAddProjectRolesRequest, resourceOwner string) []*command.AddProjectRole { + roles := make([]*command.AddProjectRole, len(req.Roles)) for i, role := range req.Roles { - roles[i] = &domain.ProjectRole{ + roles[i] = &command.AddProjectRole{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, + AggregateID: req.ProjectId, + ResourceOwner: resourceOwner, }, Key: role.Key, DisplayName: role.DisplayName, @@ -74,10 +84,11 @@ func BulkAddProjectRolesRequestToDomain(req *mgmt_pb.BulkAddProjectRolesRequest) return roles } -func UpdateProjectRoleRequestToDomain(req *mgmt_pb.UpdateProjectRoleRequest) *domain.ProjectRole { - return &domain.ProjectRole{ +func UpdateProjectRoleRequestToCommand(req *mgmt_pb.UpdateProjectRoleRequest, resourceOwner string) *command.ChangeProjectRole { + return &command.ChangeProjectRole{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, + AggregateID: req.ProjectId, + ResourceOwner: resourceOwner, }, Key: req.RoleKey, DisplayName: req.DisplayName, diff --git a/internal/api/grpc/management/project_grant.go b/internal/api/grpc/management/project_grant.go index cea8e929a4..e9313c1327 100644 --- a/internal/api/grpc/management/project_grant.go +++ b/internal/api/grpc/management/project_grant.go @@ -31,7 +31,7 @@ func (s *Server) ListProjectGrants(ctx context.Context, req *mgmt_pb.ListProject if err != nil { return nil, err } - grants, err := s.query.SearchProjectGrants(ctx, queries) + grants, err := s.query.SearchProjectGrants(ctx, queries, nil) if err != nil { return nil, err } @@ -54,7 +54,7 @@ func (s *Server) ListAllProjectGrants(ctx context.Context, req *mgmt_pb.ListAllP if err != nil { return nil, err } - grants, err := s.query.SearchProjectGrants(ctx, queries) + grants, err := s.query.SearchProjectGrants(ctx, queries, nil) if err != nil { return nil, err } @@ -65,16 +65,17 @@ func (s *Server) ListAllProjectGrants(ctx context.Context, req *mgmt_pb.ListAllP } func (s *Server) AddProjectGrant(ctx context.Context, req *mgmt_pb.AddProjectGrantRequest) (*mgmt_pb.AddProjectGrantResponse, error) { - grant, err := s.command.AddProjectGrant(ctx, AddProjectGrantRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + grant := AddProjectGrantRequestToCommand(req, "", authz.GetCtxData(ctx).OrgID) + details, err := s.command.AddProjectGrant(ctx, grant) if err != nil { return nil, err } return &mgmt_pb.AddProjectGrantResponse{ GrantId: grant.GrantID, Details: object_grpc.AddToDetailsPb( - grant.Sequence, - grant.ChangeDate, - grant.ResourceOwner, + details.Sequence, + details.EventDate, + details.ResourceOwner, ), }, nil } @@ -94,14 +95,14 @@ func (s *Server) UpdateProjectGrant(ctx context.Context, req *mgmt_pb.UpdateProj if err != nil { return nil, err } - grant, err := s.command.ChangeProjectGrant(ctx, UpdateProjectGrantRequestToDomain(req), authz.GetCtxData(ctx).OrgID, userGrantsToIDs(grants.UserGrants)...) + grant, err := s.command.ChangeProjectGrant(ctx, UpdateProjectGrantRequestToCommand(req, authz.GetCtxData(ctx).OrgID), userGrantsToIDs(grants.UserGrants)...) if err != nil { return nil, err } return &mgmt_pb.UpdateProjectGrantResponse{ Details: object_grpc.ChangeToDetailsPb( grant.Sequence, - grant.ChangeDate, + grant.EventDate, grant.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/project_grant_converter.go b/internal/api/grpc/management/project_grant_converter.go index de7d1de041..04bc35301f 100644 --- a/internal/api/grpc/management/project_grant_converter.go +++ b/internal/api/grpc/management/project_grant_converter.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" member_grpc "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" @@ -100,20 +101,23 @@ func AllProjectGrantQueryToModel(apiQuery *proj_pb.AllProjectGrantQuery) (query. return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-M099f", "List.Query.Invalid") } } -func AddProjectGrantRequestToDomain(req *mgmt_pb.AddProjectGrantRequest) *domain.ProjectGrant { - return &domain.ProjectGrant{ +func AddProjectGrantRequestToCommand(req *mgmt_pb.AddProjectGrantRequest, grantID string, resourceOwner string) *command.AddProjectGrant { + return &command.AddProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, + AggregateID: req.ProjectId, + ResourceOwner: resourceOwner, }, + GrantID: grantID, GrantedOrgID: req.GrantedOrgId, RoleKeys: req.RoleKeys, } } -func UpdateProjectGrantRequestToDomain(req *mgmt_pb.UpdateProjectGrantRequest) *domain.ProjectGrant { - return &domain.ProjectGrant{ +func UpdateProjectGrantRequestToCommand(req *mgmt_pb.UpdateProjectGrantRequest, resourceOwner string) *command.ChangeProjectGrant { + return &command.ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, + AggregateID: req.ProjectId, + ResourceOwner: resourceOwner, }, GrantID: req.GrantId, RoleKeys: req.RoleKeys, diff --git a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go index 64334bd8b1..187dc922fc 100644 --- a/internal/api/grpc/oidc/v2/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2/integration_test/oidc_test.go @@ -24,8 +24,7 @@ import ( ) func TestServer_GetAuthRequest(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) @@ -98,8 +97,7 @@ func TestServer_GetAuthRequest(t *testing.T) { } func TestServer_CreateCallback(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) clientV2, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) @@ -288,7 +286,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) + client, err := Instance.CreateOIDCImplicitFlowClient(CTX, t, redirectURIImplicit, nil) require.NoError(t, err) authRequestID, err := Instance.CreateOIDCAuthRequestImplicit(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURIImplicit) require.NoError(t, err) @@ -315,7 +313,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTXLoginClient, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - clientV2, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, loginV2) + clientV2, err := Instance.CreateOIDCImplicitFlowClient(CTX, t, redirectURIImplicit, loginV2) require.NoError(t, err) authRequestID, err := Instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURIImplicit) require.NoError(t, err) @@ -371,7 +369,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID2, _ := createOIDCApplication(ctx, t, true, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID2, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) @@ -386,7 +384,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) @@ -407,9 +405,9 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) - Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, orgResp.GetOrganizationId(), user.GetUserId()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) }, @@ -544,9 +542,9 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, false) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) - Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, orgResp.GetOrganizationId(), user.GetUserId()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) }, want: &oidc_pb.CreateCallbackResponse{ @@ -564,7 +562,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, false) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) }, @@ -606,7 +604,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, false, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) @@ -639,8 +637,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { } func TestServer_GetDeviceAuthorizationRequest(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE) require.NoError(t, err) @@ -697,8 +694,7 @@ func TestServer_GetDeviceAuthorizationRequest(t *testing.T) { } func TestServer_AuthorizeOrDenyDeviceAuthorization(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE) require.NoError(t, err) sessionResp := createSession(t, CTX, Instance.Users[integration.UserTypeOrgOwner].ID) @@ -895,8 +891,7 @@ func createSessionAndAuthRequestForCallback(ctx context.Context, t *testing.T, c } func createOIDCApplication(ctx context.Context, t *testing.T, projectRoleCheck, hasProjectCheck bool) (string, string) { - project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) - require.NoError(t, err) + project := Instance.CreateProject(ctx, t, "", gofakeit.AppName(), projectRoleCheck, hasProjectCheck) clientV2, err := Instance.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) return project.GetId(), clientV2.GetClientId() diff --git a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go index d7d746e2d0..bd02f9e068 100644 --- a/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go +++ b/internal/api/grpc/oidc/v2beta/integration_test/oidc_test.go @@ -23,8 +23,7 @@ import ( ) func TestServer_GetAuthRequest(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) @@ -97,8 +96,7 @@ func TestServer_GetAuthRequest(t *testing.T) { } func TestServer_CreateCallback(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) client, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) clientV2, err := Instance.CreateOIDCClientLoginVersion(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) @@ -289,7 +287,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTX, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - client, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) + client, err := Instance.CreateOIDCImplicitFlowClient(CTX, t, redirectURIImplicit, nil) require.NoError(t, err) authRequestID, err := Instance.CreateOIDCAuthRequestImplicit(CTX, client.GetClientId(), Instance.Users.Get(integration.UserTypeOrgOwner).ID, redirectURIImplicit) require.NoError(t, err) @@ -316,7 +314,7 @@ func TestServer_CreateCallback(t *testing.T) { ctx: CTXLoginClient, req: &oidc_pb.CreateCallbackRequest{ AuthRequestId: func() string { - clientV2, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, loginV2) + clientV2, err := Instance.CreateOIDCImplicitFlowClient(CTX, t, redirectURIImplicit, loginV2) require.NoError(t, err) authRequestID, err := Instance.CreateOIDCAuthRequestImplicitWithoutLoginClientHeader(CTX, clientV2.GetClientId(), redirectURIImplicit) require.NoError(t, err) @@ -372,7 +370,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID2, _ := createOIDCApplication(ctx, t, true, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID2, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) @@ -387,7 +385,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) @@ -408,9 +406,9 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) - Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, orgResp.GetOrganizationId(), user.GetUserId()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) }, @@ -545,9 +543,9 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, false) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) - Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, orgResp.GetOrganizationId(), user.GetUserId()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) }, want: &oidc_pb.CreateCallbackResponse{ @@ -565,7 +563,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, true, false) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) }, @@ -607,7 +605,7 @@ func TestServer_CreateCallback_Permission(t *testing.T) { projectID, clientID := createOIDCApplication(ctx, t, false, true) orgResp := Instance.CreateOrganization(ctx, "oidc-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) return createSessionAndAuthRequestForCallback(ctx, t, clientID, Instance.Users.Get(integration.UserTypeOrgOwner).ID, user.GetUserId()) @@ -669,8 +667,7 @@ func createSessionAndAuthRequestForCallback(ctx context.Context, t *testing.T, c } func createOIDCApplication(ctx context.Context, t *testing.T, projectRoleCheck, hasProjectCheck bool) (string, string) { - project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) - require.NoError(t, err) + project := Instance.CreateProject(ctx, t, "", gofakeit.AppName(), projectRoleCheck, hasProjectCheck) clientV2, err := Instance.CreateOIDCClientLoginVersion(ctx, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, loginV2) require.NoError(t, err) return project.GetId(), clientV2.GetClientId() diff --git a/internal/api/grpc/project/v2beta/integration/project_grant_test.go b/internal/api/grpc/project/v2beta/integration/project_grant_test.go new file mode 100644 index 0000000000..8500f24d56 --- /dev/null +++ b/internal/api/grpc/project/v2beta/integration/project_grant_test.go @@ -0,0 +1,1292 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/integration" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func TestServer_CreateProjectGrant(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *project.CreateProjectGrantRequest) + req *project.CreateProjectGrantRequest + want + wantErr bool + }{ + { + name: "empty projectID", + ctx: iamOwnerCtx, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "empty granted organization", + ctx: iamOwnerCtx, + req: &project.CreateProjectGrantRequest{ + ProjectId: "something", + GrantedOrganizationId: "", + }, + wantErr: true, + }, + { + name: "project not existing", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + request.ProjectId = "something" + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "org not existing", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + request.GrantedOrganizationId = "something" + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "already existing, error", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "same organization, error", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + request.GrantedOrganizationId = orgResp.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "empty, ok", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + want: want{ + creationDate: true, + }, + }, + { + name: "with roles, not existing", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + roles := []string{gofakeit.Name(), gofakeit.Name(), gofakeit.Name()} + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + request.RoleKeys = roles + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "with roles, ok", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + roles := []string{gofakeit.Name(), gofakeit.Name(), gofakeit.Name()} + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + for _, role := range roles { + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), role, role, "") + } + + request.RoleKeys = roles + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + want: want{ + creationDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + + creationDate := time.Now().UTC() + got, err := instance.Client.Projectv2Beta.CreateProjectGrant(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateProjectGrantResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func TestServer_CreateProjectGrant_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *project.CreateProjectGrantRequest) + req *project.CreateProjectGrantRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + grantedOrgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + request.ProjectId = projectResp.GetId() + request.GrantedOrganizationId = grantedOrgResp.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + grantedOrgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + request.ProjectId = projectResp.GetId() + request.GrantedOrganizationId = grantedOrgResp.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "organization owner, other org", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + grantedOrgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + request.ProjectId = projectResp.GetId() + request.GrantedOrganizationId = grantedOrgResp.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "organization owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + want: want{ + creationDate: true, + }, + }, + { + name: "instance owner", + ctx: iamOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + grantedOrgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + request.ProjectId = projectResp.GetId() + request.GrantedOrganizationId = grantedOrgResp.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + want: want{ + creationDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + + creationDate := time.Now().UTC() + got, err := instance.Client.Projectv2Beta.CreateProjectGrant(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateProjectGrantResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertCreateProjectGrantResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate bool, actualResp *project.CreateProjectGrantResponse) { + if expectedCreationDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.CreationDate) + } +} + +func TestServer_UpdateProjectGrant(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.UpdateProjectGrantRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.UpdateProjectGrantRequest) + args args + want want + wantErr bool + }{ + { + name: "not existing", + prepare: func(request *project.UpdateProjectGrantRequest) { + request.ProjectId = "notexisting" + request.GrantedOrganizationId = "notexisting" + return + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectGrantRequest{ + RoleKeys: []string{"notexisting"}, + }, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *project.UpdateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectGrantRequest{}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "change roles, ok", + prepare: func(request *project.UpdateProjectGrantRequest) { + roles := []string{gofakeit.Animal(), gofakeit.Animal(), gofakeit.Animal()} + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + for _, role := range roles { + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), role, role, "") + } + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId(), roles...) + request.RoleKeys = roles + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "change roles, not existing", + prepare: func(request *project.UpdateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + request.RoleKeys = []string{gofakeit.Animal(), gofakeit.Animal(), gofakeit.Animal()} + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectGrantRequest{}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.prepare != nil { + tt.prepare(tt.args.req) + } + + got, err := instance.Client.Projectv2Beta.UpdateProjectGrant(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateProjectGrantResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_UpdateProjectGrant_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.UpdateProjectGrantRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.UpdateProjectGrantRequest) + args args + want want + wantErr bool + }{ + { + name: "unauthenticated", + prepare: func(request *project.UpdateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: CTX, + req: &project.UpdateProjectGrantRequest{ + RoleKeys: []string{"nopermission"}, + }, + }, + wantErr: true, + }, + { + name: "no permission", + prepare: func(request *project.UpdateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.UpdateProjectGrantRequest{ + RoleKeys: []string{"nopermission"}, + }, + }, + wantErr: true, + }, + { + name: "organization owner, other org", + prepare: func(request *project.UpdateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.UpdateProjectGrantRequest{ + RoleKeys: []string{"nopermission"}, + }, + }, + wantErr: true, + }, + { + name: "organization owner, ok", + prepare: func(request *project.UpdateProjectGrantRequest) { + roles := []string{gofakeit.Animal(), gofakeit.Animal(), gofakeit.Animal()} + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + for _, role := range roles { + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), role, role, "") + } + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId(), roles...) + request.RoleKeys = roles + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.UpdateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance owner, ok", + prepare: func(request *project.UpdateProjectGrantRequest) { + roles := []string{gofakeit.Animal(), gofakeit.Animal(), gofakeit.Animal()} + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + for _, role := range roles { + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), role, role, "") + } + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId(), roles...) + request.RoleKeys = roles + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.prepare != nil { + tt.prepare(tt.args.req) + } + + got, err := instance.Client.Projectv2Beta.UpdateProjectGrant(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateProjectGrantResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func assertUpdateProjectGrantResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *project.UpdateProjectGrantResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} + +func TestServer_DeleteProjectGrant(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + prepare func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) + req *project.DeleteProjectGrantRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "empty project id", + ctx: iamOwnerCtx, + req: &project.DeleteProjectGrantRequest{ + ProjectId: "", + }, + wantErr: true, + }, + { + name: "empty grantedorg id", + ctx: iamOwnerCtx, + req: &project.DeleteProjectGrantRequest{ + ProjectId: "notempty", + GrantedOrganizationId: "", + }, + wantErr: true, + }, + { + name: "delete, not existing", + ctx: iamOwnerCtx, + req: &project.DeleteProjectGrantRequest{ + ProjectId: "notexisting", + GrantedOrganizationId: "notexisting", + }, + wantErr: true, + }, + { + name: "delete", + ctx: iamOwnerCtx, + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return creationDate, time.Time{} + }, + req: &project.DeleteProjectGrantRequest{}, + wantDeletionDate: true, + }, + { + name: "delete deactivated", + ctx: iamOwnerCtx, + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeactivateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return creationDate, time.Time{} + }, + req: &project.DeleteProjectGrantRequest{}, + wantDeletionDate: true, + }, + { + name: "delete, already removed", + ctx: iamOwnerCtx, + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeleteProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return creationDate, time.Now().UTC() + }, + req: &project.DeleteProjectGrantRequest{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.Projectv2Beta.DeleteProjectGrant(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteProjectGrantResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func TestServer_DeleteProjectGrant_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + prepare func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) + req *project.DeleteProjectGrantRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return time.Time{}, time.Time{} + }, + req: &project.DeleteProjectGrantRequest{}, + wantErr: true, + }, + { + name: "no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return time.Time{}, time.Time{} + }, + req: &project.DeleteProjectGrantRequest{}, + wantErr: true, + }, + { + name: "organization owner, other org", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return creationDate, time.Time{} + }, + req: &project.DeleteProjectGrantRequest{}, + wantErr: true, + }, + { + name: "organization owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return creationDate, time.Time{} + }, + req: &project.DeleteProjectGrantRequest{}, + wantDeletionDate: true, + }, + { + name: "organization owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *project.DeleteProjectGrantRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + return creationDate, time.Time{} + }, + req: &project.DeleteProjectGrantRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.Projectv2Beta.DeleteProjectGrant(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteProjectGrantResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func assertDeleteProjectGrantResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *project.DeleteProjectGrantResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} + +func TestServer_DeactivateProjectGrant(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.DeactivateProjectGrantRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.DeactivateProjectGrantRequest) + args args + want want + wantErr bool + }{ + { + name: "missing permission", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.DeactivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "not existing", + prepare: func(request *project.DeactivateProjectGrantRequest) { + request.ProjectId = "notexisting" + request.GrantedOrganizationId = "notexisting" + return + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeactivateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectGrantRequest{}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "change, ok", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.DeactivateProjectGrant(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertDeactivateProjectGrantResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_DeactivateProjectGrant_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.DeactivateProjectGrantRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.DeactivateProjectGrantRequest) + args args + want want + wantErr bool + }{ + { + name: "unauthenticated", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: CTX, + req: &project.DeactivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "no permission", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.DeactivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner, other org", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.DeactivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner, ok", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.DeactivateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance owner, ok", + prepare: func(request *project.DeactivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.DeactivateProjectGrant(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertDeactivateProjectGrantResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func assertDeactivateProjectGrantResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *project.DeactivateProjectGrantResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} + +func TestServer_ActivateProjectGrant(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.ActivateProjectGrantRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.ActivateProjectGrantRequest) + args args + want want + wantErr bool + }{ + { + name: "not existing", + prepare: func(request *project.ActivateProjectGrantRequest) { + request.ProjectId = "notexisting" + request.GrantedOrganizationId = "notexisting" + return + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *project.ActivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectGrantRequest{}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "change, ok", + prepare: func(request *project.ActivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeactivateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.ActivateProjectGrant(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertActivateProjectGrantResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_ActivateProjectGrant_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.ActivateProjectGrantRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.ActivateProjectGrantRequest) + args args + want want + wantErr bool + }{ + { + name: "unauthenticated", + prepare: func(request *project.ActivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: CTX, + req: &project.ActivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "no permission", + prepare: func(request *project.ActivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeactivateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.ActivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner, other org", + prepare: func(request *project.ActivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeactivateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.ActivateProjectGrantRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner, ok", + prepare: func(request *project.ActivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeactivateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.ActivateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance owner, ok", + prepare: func(request *project.ActivateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + instance.DeactivateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectGrantRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.ActivateProjectGrant(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertActivateProjectGrantResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func assertActivateProjectGrantResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *project.ActivateProjectGrantResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} diff --git a/internal/api/grpc/project/v2beta/integration/project_role_test.go b/internal/api/grpc/project/v2beta/integration/project_role_test.go new file mode 100644 index 0000000000..5e2f0e447e --- /dev/null +++ b/internal/api/grpc/project/v2beta/integration/project_role_test.go @@ -0,0 +1,698 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/integration" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func TestServer_AddProjectRole(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + alreadyExistingProject := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + alreadyExistingProjectRoleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, alreadyExistingProject.GetId(), alreadyExistingProjectRoleName, alreadyExistingProjectRoleName, "") + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *project.AddProjectRoleRequest) + req *project.AddProjectRoleRequest + want + wantErr bool + }{ + { + name: "empty key", + ctx: iamOwnerCtx, + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: "", + DisplayName: gofakeit.AppName(), + }, + wantErr: true, + }, + { + name: "empty displayname", + ctx: iamOwnerCtx, + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: gofakeit.AppName(), + DisplayName: "", + }, + wantErr: true, + }, + { + name: "already existing, error", + ctx: iamOwnerCtx, + prepare: func(request *project.AddProjectRoleRequest) { + request.ProjectId = alreadyExistingProject.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: alreadyExistingProjectRoleName, + DisplayName: alreadyExistingProjectRoleName, + }, + wantErr: true, + }, + { + name: "empty, ok", + ctx: iamOwnerCtx, + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: gofakeit.Name(), + DisplayName: gofakeit.Name(), + }, + want: want{ + creationDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + + creationDate := time.Now().UTC() + got, err := instance.Client.Projectv2Beta.AddProjectRole(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertAddProjectRoleResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func TestServer_AddProjectRole_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + alreadyExistingProject := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + alreadyExistingProjectRoleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, alreadyExistingProject.GetId(), alreadyExistingProjectRoleName, alreadyExistingProjectRoleName, "") + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *project.AddProjectRoleRequest) + req *project.AddProjectRoleRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: gofakeit.AppName(), + DisplayName: gofakeit.AppName(), + }, + wantErr: true, + }, + { + name: "no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: gofakeit.AppName(), + DisplayName: gofakeit.AppName(), + }, + wantErr: true, + }, + { + name: "organization owner, other org", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: gofakeit.AppName(), + DisplayName: gofakeit.AppName(), + }, + wantErr: true, + }, + { + name: "organization owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: gofakeit.AppName(), + DisplayName: gofakeit.AppName(), + }, + want: want{ + creationDate: true, + }, + }, + { + name: "instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *project.AddProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.ProjectId = projectResp.GetId() + }, + req: &project.AddProjectRoleRequest{ + RoleKey: gofakeit.AppName(), + DisplayName: gofakeit.AppName(), + }, + want: want{ + creationDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + + creationDate := time.Now().UTC() + got, err := instance.Client.Projectv2Beta.AddProjectRole(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertAddProjectRoleResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertAddProjectRoleResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate bool, actualResp *project.AddProjectRoleResponse) { + if expectedCreationDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.CreationDate) + } +} + +func TestServer_UpdateProjectRole(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.UpdateProjectRoleRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.UpdateProjectRoleRequest) + args args + want want + wantErr bool + }{ + { + name: "missing permission", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr("changed"), + }, + }, + wantErr: true, + }, + { + name: "not existing", + prepare: func(request *project.UpdateProjectRoleRequest) { + request.RoleKey = "notexisting" + return + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr("changed"), + }, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + request.DisplayName = gu.Ptr(roleName) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRoleRequest{}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "change display name, ok", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "change full, ok", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr(gofakeit.AppName()), + Group: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.UpdateProjectRole(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateProjectRoleResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_UpdateProjectRole_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.UpdateProjectRoleRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.UpdateProjectRoleRequest) + args args + want want + wantErr bool + }{ + { + name: "unauthenicated", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: CTX, + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr("changed"), + }, + }, + wantErr: true, + }, + { + name: "no permission", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr("changed"), + }, + }, + wantErr: true, + }, + { + name: "organization owner, other org", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr("changed"), + }, + }, + wantErr: true, + }, + { + name: "organization owner, ok", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance owner, ok", + prepare: func(request *project.UpdateProjectRoleRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRoleRequest{ + DisplayName: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.UpdateProjectRole(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateProjectRoleResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func assertUpdateProjectRoleResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *project.UpdateProjectRoleResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} + +func TestServer_DeleteProjectRole(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + prepare func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) + req *project.RemoveProjectRoleRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "empty id", + ctx: iamOwnerCtx, + req: &project.RemoveProjectRoleRequest{ + ProjectId: "", + RoleKey: "notexisting", + }, + wantErr: true, + }, + { + name: "delete, not existing", + ctx: iamOwnerCtx, + req: &project.RemoveProjectRoleRequest{ + ProjectId: "notexisting", + RoleKey: "notexisting", + }, + wantDeletionDate: false, + }, + { + name: "delete", + ctx: iamOwnerCtx, + prepare: func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + return creationDate, time.Time{} + }, + req: &project.RemoveProjectRoleRequest{}, + wantDeletionDate: true, + }, + { + name: "delete, already removed", + ctx: iamOwnerCtx, + prepare: func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + instance.RemoveProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName) + return creationDate, time.Now().UTC() + }, + req: &project.RemoveProjectRoleRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.Projectv2Beta.RemoveProjectRole(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertRemoveProjectRoleResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func TestServer_DeleteProjectRole_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + prepare func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) + req *project.RemoveProjectRoleRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + return creationDate, time.Time{} + }, + req: &project.RemoveProjectRoleRequest{}, + wantErr: true, + }, + { + name: "no permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + return creationDate, time.Time{} + }, + req: &project.RemoveProjectRoleRequest{}, + wantErr: true, + }, + { + name: "organization owner, other org", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + return creationDate, time.Time{} + }, + req: &project.RemoveProjectRoleRequest{}, + wantErr: true, + }, + { + name: "organization owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + return creationDate, time.Time{} + }, + req: &project.RemoveProjectRoleRequest{}, + wantDeletionDate: true, + }, + { + name: "instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *project.RemoveProjectRoleRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + roleName := gofakeit.AppName() + instance.AddProjectRole(iamOwnerCtx, t, projectResp.GetId(), roleName, roleName, "") + request.ProjectId = projectResp.GetId() + request.RoleKey = roleName + return creationDate, time.Time{} + }, + req: &project.RemoveProjectRoleRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.Projectv2Beta.RemoveProjectRole(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertRemoveProjectRoleResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func assertRemoveProjectRoleResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *project.RemoveProjectRoleResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetRemovalDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetRemovalDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.RemovalDate) + } +} diff --git a/internal/api/grpc/project/v2beta/integration/project_test.go b/internal/api/grpc/project/v2beta/integration/project_test.go new file mode 100644 index 0000000000..6c0a5c96f6 --- /dev/null +++ b/internal/api/grpc/project/v2beta/integration/project_test.go @@ -0,0 +1,1013 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/integration" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func TestServer_CreateProject(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + alreadyExistingProjectName := gofakeit.AppName() + instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), alreadyExistingProjectName, false, false) + + type want struct { + id bool + creationDate bool + } + tests := []struct { + name string + ctx context.Context + req *project.CreateProjectRequest + want + wantErr bool + }{ + { + name: "empty name", + ctx: iamOwnerCtx, + req: &project.CreateProjectRequest{ + Name: "", + }, + wantErr: true, + }, + { + name: "empty organization", + ctx: iamOwnerCtx, + req: &project.CreateProjectRequest{ + Name: gofakeit.Name(), + OrganizationId: "", + }, + wantErr: true, + }, + { + name: "already existing, error", + ctx: iamOwnerCtx, + req: &project.CreateProjectRequest{ + Name: alreadyExistingProjectName, + OrganizationId: orgResp.GetOrganizationId(), + }, + wantErr: true, + }, + { + name: "empty, ok", + ctx: iamOwnerCtx, + req: &project.CreateProjectRequest{ + Name: gofakeit.Name(), + OrganizationId: orgResp.GetOrganizationId(), + }, + want: want{ + id: true, + creationDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + got, err := instance.Client.Projectv2Beta.CreateProject(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateProjectResponse(t, creationDate, changeDate, tt.want.creationDate, tt.want.id, got) + }) + } +} + +func TestServer_CreateProject_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type want struct { + id bool + creationDate bool + } + tests := []struct { + name string + ctx context.Context + req *project.CreateProjectRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + req: &project.CreateProjectRequest{ + Name: gofakeit.Name(), + OrganizationId: orgResp.GetOrganizationId(), + }, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.CreateProjectRequest{ + Name: gofakeit.Name(), + OrganizationId: orgResp.GetOrganizationId(), + }, + wantErr: true, + }, + { + name: "missing permission, other organization", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.CreateProjectRequest{ + Name: gofakeit.Name(), + OrganizationId: orgResp.GetOrganizationId(), + }, + wantErr: true, + }, + { + name: "organization owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.CreateProjectRequest{ + Name: gofakeit.Name(), + OrganizationId: instance.DefaultOrg.GetId(), + }, + want: want{ + id: true, + creationDate: true, + }, + }, + { + name: "instance owner, ok", + ctx: iamOwnerCtx, + req: &project.CreateProjectRequest{ + Name: gofakeit.Name(), + OrganizationId: orgResp.GetOrganizationId(), + }, + want: want{ + id: true, + creationDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + got, err := instance.Client.Projectv2Beta.CreateProject(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateProjectResponse(t, creationDate, changeDate, tt.want.creationDate, tt.want.id, got) + }) + } +} + +func assertCreateProjectResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate, expectedID bool, actualResp *project.CreateProjectResponse) { + if expectedCreationDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.CreationDate) + } + + if expectedID { + assert.NotEmpty(t, actualResp.GetId()) + } else { + assert.Nil(t, actualResp.Id) + } +} + +func TestServer_UpdateProject(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.UpdateProjectRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.UpdateProjectRequest) + args args + want want + wantErr bool + }{ + { + name: "not existing", + prepare: func(request *project.UpdateProjectRequest) { + request.Id = "notexisting" + return + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.Name()), + }, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *project.UpdateProjectRequest) { + name := gofakeit.AppName() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false).GetId() + request.Id = projectID + request.Name = gu.Ptr(name) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRequest{}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "change name, ok", + prepare: func(request *project.UpdateProjectRequest) { + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "change full, ok", + prepare: func(request *project.UpdateProjectRequest) { + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.AppName()), + ProjectRoleAssertion: gu.Ptr(true), + ProjectRoleCheck: gu.Ptr(true), + HasProjectCheck: gu.Ptr(true), + PrivateLabelingSetting: gu.Ptr(project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.UpdateProject(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateProjectResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_UpdateProject_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.UpdateProjectRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.UpdateProjectRequest) + args args + want want + wantErr bool + }{ + { + name: "unauthenticated", + prepare: func(request *project.UpdateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: CTX, + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.Name()), + }, + }, + wantErr: true, + }, + { + name: "missing permission", + prepare: func(request *project.UpdateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.Name()), + }, + }, + wantErr: true, + }, + { + name: "missing permission, other organization", + prepare: func(request *project.UpdateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.Name()), + }, + }, + wantErr: true, + }, + { + name: "organization owner, ok", + prepare: func(request *project.UpdateProjectRequest) { + projectID := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance owner, ok", + prepare: func(request *project.UpdateProjectRequest) { + projectID := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.UpdateProject(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateProjectResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func assertUpdateProjectResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *project.UpdateProjectResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} + +func TestServer_DeleteProject(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + prepare func(request *project.DeleteProjectRequest) (time.Time, time.Time) + req *project.DeleteProjectRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "empty id", + ctx: iamOwnerCtx, + req: &project.DeleteProjectRequest{ + Id: "", + }, + wantErr: true, + }, + { + name: "delete, not existing", + ctx: iamOwnerCtx, + req: &project.DeleteProjectRequest{ + Id: "notexisting", + }, + wantDeletionDate: false, + }, + { + name: "delete", + ctx: iamOwnerCtx, + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantDeletionDate: true, + }, + { + name: "delete, already removed", + ctx: iamOwnerCtx, + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + instance.DeleteProject(iamOwnerCtx, t, projectID) + return creationDate, time.Now().UTC() + }, + req: &project.DeleteProjectRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.Projectv2Beta.DeleteProject(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteProjectResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func TestServer_DeleteProject_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + tests := []struct { + name string + ctx context.Context + prepare func(request *project.DeleteProjectRequest) (time.Time, time.Time) + req *project.DeleteProjectRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantErr: true, + }, + { + name: "organization owner, other org", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantErr: true, + }, + { + name: "organization owner", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantDeletionDate: true, + }, + { + name: "instance owner", + ctx: iamOwnerCtx, + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.Projectv2Beta.DeleteProject(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteProjectResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func assertDeleteProjectResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *project.DeleteProjectResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} + +func TestServer_DeactivateProject(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.DeactivateProjectRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.DeactivateProjectRequest) + args args + want want + wantErr bool + }{ + { + name: "not existing", + prepare: func(request *project.DeactivateProjectRequest) { + request.Id = "notexisting" + return + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *project.DeactivateProjectRequest) { + name := gofakeit.AppName() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false).GetId() + request.Id = projectID + instance.DeactivateProject(iamOwnerCtx, t, projectID) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "change, ok", + prepare: func(request *project.DeactivateProjectRequest) { + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.DeactivateProject(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertDeactivateProjectResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_DeactivateProject_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.DeactivateProjectRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.DeactivateProjectRequest) + args args + want want + wantErr bool + }{ + { + name: "unauthenticated", + prepare: func(request *project.DeactivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: CTX, + req: &project.DeactivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "missing permission", + prepare: func(request *project.DeactivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.DeactivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner, other org", + prepare: func(request *project.DeactivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.DeactivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner", + prepare: func(request *project.DeactivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.DeactivateProjectRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance owner", + prepare: func(request *project.DeactivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.DeactivateProjectRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.DeactivateProject(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertDeactivateProjectResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func assertDeactivateProjectResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *project.DeactivateProjectResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} + +func TestServer_ActivateProject(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.ActivateProjectRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.ActivateProjectRequest) + args args + want want + wantErr bool + }{ + { + name: "missing permission", + prepare: func(request *project.ActivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.ActivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "not existing", + prepare: func(request *project.ActivateProjectRequest) { + request.Id = "notexisting" + return + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "no change, ok", + prepare: func(request *project.ActivateProjectRequest) { + name := gofakeit.AppName() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false).GetId() + request.Id = projectID + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "change, ok", + prepare: func(request *project.ActivateProjectRequest) { + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + instance.DeactivateProject(iamOwnerCtx, t, projectID) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.ActivateProject(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertActivateProjectResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_ActivateProject_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + type args struct { + ctx context.Context + req *project.ActivateProjectRequest + } + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + prepare func(request *project.ActivateProjectRequest) + args args + want want + wantErr bool + }{ + { + name: "unauthenticated", + prepare: func(request *project.ActivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + instance.DeactivateProject(iamOwnerCtx, t, projectResp.GetId()) + }, + args: args{ + ctx: CTX, + req: &project.ActivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "missing permission", + prepare: func(request *project.ActivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + instance.DeactivateProject(iamOwnerCtx, t, projectResp.GetId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &project.ActivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner, other org", + prepare: func(request *project.ActivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + instance.DeactivateProject(iamOwnerCtx, t, projectResp.GetId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.ActivateProjectRequest{}, + }, + wantErr: true, + }, + { + name: "organization owner", + prepare: func(request *project.ActivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + instance.DeactivateProject(iamOwnerCtx, t, projectResp.GetId()) + }, + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &project.ActivateProjectRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance owner", + prepare: func(request *project.ActivateProjectRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + request.Id = projectResp.GetId() + instance.DeactivateProject(iamOwnerCtx, t, projectResp.GetId()) + }, + args: args{ + ctx: iamOwnerCtx, + req: &project.ActivateProjectRequest{}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + tt.prepare(tt.args.req) + + got, err := instance.Client.Projectv2Beta.ActivateProject(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(t, err) + return + } + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertActivateProjectResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func assertActivateProjectResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *project.ActivateProjectResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} diff --git a/internal/api/grpc/project/v2beta/integration/query_test.go b/internal/api/grpc/project/v2beta/integration/query_test.go new file mode 100644 index 0000000000..f959bfe2f8 --- /dev/null +++ b/internal/api/grpc/project/v2beta/integration/query_test.go @@ -0,0 +1,1738 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func TestServer_GetProject(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + dep func(*project.GetProjectRequest, *project.GetProjectResponse) + req *project.GetProjectRequest + } + tests := []struct { + name string + args args + want *project.GetProjectResponse + wantErr bool + }{ + { + name: "missing permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *project.GetProjectRequest, response *project.GetProjectResponse) { + orgID := instance.DefaultOrg.GetId() + resp := createProject(iamOwnerCtx, instance, t, orgID, false, false) + + request.Id = resp.GetId() + }, + req: &project.GetProjectRequest{}, + }, + wantErr: true, + }, + { + name: "missing permission, other org owner", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.GetProjectRequest, response *project.GetProjectResponse) { + name := gofakeit.AppName() + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) + + request.Id = resp.GetId() + }, + req: &project.GetProjectRequest{}, + }, + wantErr: true, + }, + { + name: "not found", + args: args{ + ctx: iamOwnerCtx, + req: &project.GetProjectRequest{Id: "notexisting"}, + }, + wantErr: true, + }, + { + name: "get, ok", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.GetProjectRequest, response *project.GetProjectResponse) { + orgID := instance.DefaultOrg.GetId() + resp := createProject(iamOwnerCtx, instance, t, orgID, false, false) + + request.Id = resp.GetId() + response.Project = resp + }, + req: &project.GetProjectRequest{}, + }, + want: &project.GetProjectResponse{ + Project: &project.Project{ + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: false, + AuthorizationRequired: false, + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + }, + }, + }, + { + name: "get, ok, org owner", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.GetProjectRequest, response *project.GetProjectResponse) { + orgID := instance.DefaultOrg.GetId() + resp := createProject(iamOwnerCtx, instance, t, orgID, false, false) + + request.Id = resp.GetId() + response.Project = resp + }, + req: &project.GetProjectRequest{}, + }, + want: &project.GetProjectResponse{ + Project: &project.Project{}, + }, + }, + { + name: "get, full, ok", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.GetProjectRequest, response *project.GetProjectResponse) { + orgID := instance.DefaultOrg.GetId() + resp := createProject(iamOwnerCtx, instance, t, orgID, true, true) + + request.Id = resp.GetId() + response.Project = resp + }, + req: &project.GetProjectRequest{}, + }, + want: &project.GetProjectResponse{ + Project: &project.Project{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, 2*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := instance.Client.Projectv2Beta.GetProject(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ttt, err) + return + } + assert.NoError(ttt, err) + assert.EqualExportedValues(ttt, tt.want, got) + }, retryDuration, tick, "timeout waiting for expected project result") + }) + } +} + +func TestServer_ListProjects(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + dep func(*project.ListProjectsRequest, *project.ListProjectsResponse) + req *project.ListProjectsRequest + } + tests := []struct { + name string + args args + want *project.ListProjectsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + name := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + resp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp.GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + name := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + resp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp.GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + name := gofakeit.AppName() + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp.GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{ + {Filter: &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{"notfound"}, + }, + }, + }, + }, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + response.Projects[0] = createProject(iamOwnerCtx, instance, t, orgID, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{response.Projects[0].GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, + { + name: "list single name", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + response.Projects[0] = createProject(iamOwnerCtx, instance, t, orgID, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectNameFilter{ + ProjectNameFilter: &project.ProjectNameFilter{ + ProjectName: response.Projects[0].Name, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + { + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: false, + AuthorizationRequired: false, + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + }, + }, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + response.Projects[2] = createProject(iamOwnerCtx, instance, t, orgID, false, false) + response.Projects[1] = createProject(iamOwnerCtx, instance, t, orgID, true, false) + response.Projects[0] = createProject(iamOwnerCtx, instance, t, orgID, false, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + {}, + {}, + }, + }, + }, + { + name: "list multiple id, limited permissions", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + resp1 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, false) + resp2 := createProject(iamOwnerCtx, instance, t, orgID, true, false) + resp3 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, + }, + } + + response.Projects[0] = resp2 + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, + { + name: "list project and granted projects", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + projectResp := createProject(iamOwnerCtx, instance, t, orgID, true, true) + response.Projects[3] = projectResp + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + response.Projects[2] = createGrantedProject(iamOwnerCtx, instance, t, projectResp) + response.Projects[1] = createGrantedProject(iamOwnerCtx, instance, t, projectResp) + response.Projects[0] = createGrantedProject(iamOwnerCtx, instance, t, projectResp) + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + {}, + {}, + {}, + }, + }, + }, + { + name: "list project and granted projects, organization", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + projectResp := createProject(iamOwnerCtx, instance, t, orgID, true, true) + + grantedProjectResp := createGrantedProject(iamOwnerCtx, instance, t, projectResp) + response.Projects[1] = grantedProjectResp + response.Projects[0] = createProject(iamOwnerCtx, instance, t, *grantedProjectResp.GrantedOrganizationId, true, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectOrganizationIdFilter{ + ProjectOrganizationIdFilter: &project.ProjectOrganizationIDFilter{ProjectOrganizationId: *grantedProjectResp.GrantedOrganizationId}, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + {}, + }, + }, + }, + { + name: "list project and granted projects, project resourceowner", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + projectResp := createProject(iamOwnerCtx, instance, t, orgID, true, true) + + grantedProjectResp := createGrantedProject(iamOwnerCtx, instance, t, projectResp) + response.Projects[0] = createProject(iamOwnerCtx, instance, t, *grantedProjectResp.GrantedOrganizationId, true, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectResourceOwnerFilter{ + ProjectResourceOwnerFilter: &project.ProjectResourceOwnerFilter{ProjectResourceOwner: *grantedProjectResp.GrantedOrganizationId}, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.Projectv2Beta.ListProjects(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Projects, len(tt.want.Projects)) { + for i := range tt.want.Projects { + assert.EqualExportedValues(ttt, tt.want.Projects[i], got.Projects[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func TestServer_ListProjects_PermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgID := instancePermissionV2.DefaultOrg.GetId() + + type args struct { + ctx context.Context + dep func(*project.ListProjectsRequest, *project.ListProjectsResponse) + req *project.ListProjectsRequest + } + tests := []struct { + name string + args args + want *project.ListProjectsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp.GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp.GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp.GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Projects: []*project.Project{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{ + {Filter: &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{"notfound"}, + }, + }, + }, + }, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{response.Projects[0].GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, + { + name: "list single name", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) + request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectNameFilter{ + ProjectNameFilter: &project.ProjectNameFilter{ + ProjectName: response.Projects[0].Name, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + response.Projects[2] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) + response.Projects[1] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, false) + response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, + }, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + {}, + {}, + }, + }, + }, + { + name: "list project and granted projects", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + projectResp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, true) + response.Projects[3] = projectResp + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + response.Projects[2] = createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) + response.Projects[1] = createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) + response.Projects[0] = createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + {}, + {}, + {}, + }, + }, + }, + { + name: "list project and granted projects, organization", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + projectResp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, true) + + grantedProjectResp := createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) + response.Projects[1] = grantedProjectResp + response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, *grantedProjectResp.GrantedOrganizationId, true, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectOrganizationIdFilter{ + ProjectOrganizationIdFilter: &project.ProjectOrganizationIDFilter{ProjectOrganizationId: *grantedProjectResp.GrantedOrganizationId}, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + {}, + }, + }, + }, + { + name: "list project and granted projects, project resourceowner", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + projectResp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, true) + + grantedProjectResp := createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) + response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, *grantedProjectResp.GrantedOrganizationId, true, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectResourceOwnerFilter{ + ProjectResourceOwnerFilter: &project.ProjectResourceOwnerFilter{ProjectResourceOwner: *grantedProjectResp.GrantedOrganizationId}, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, + { + name: "list multiple id, limited permissions", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + resp1 := createProject(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), false, false) + resp2 := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, false) + resp3 := createProject(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), false, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, + }, + } + + response.Projects[0] = resp2 + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instancePermissionV2.Client.Projectv2Beta.ListProjects(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Projects, len(tt.want.Projects)) { + for i := range tt.want.Projects { + assert.EqualExportedValues(ttt, tt.want.Projects[i], got.Projects[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func createProject(ctx context.Context, instance *integration.Instance, t *testing.T, orgID string, projectRoleCheck, hasProjectCheck bool) *project.Project { + name := gofakeit.AppName() + resp := instance.CreateProject(ctx, t, orgID, name, projectRoleCheck, hasProjectCheck) + return &project.Project{ + Id: resp.GetId(), + Name: name, + OrganizationId: orgID, + CreationDate: resp.GetCreationDate(), + ChangeDate: resp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: hasProjectCheck, + AuthorizationRequired: projectRoleCheck, + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + } +} + +func createGrantedProject(ctx context.Context, instance *integration.Instance, t *testing.T, projectToGrant *project.Project) *project.Project { + grantedOrgName := gofakeit.AppName() + grantedOrg := instance.CreateOrganization(ctx, grantedOrgName, gofakeit.Email()) + projectGrantResp := instance.CreateProjectGrant(ctx, t, projectToGrant.GetId(), grantedOrg.GetOrganizationId()) + + return &project.Project{ + Id: projectToGrant.GetId(), + Name: projectToGrant.GetName(), + OrganizationId: projectToGrant.GetOrganizationId(), + CreationDate: projectGrantResp.GetCreationDate(), + ChangeDate: projectGrantResp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: projectToGrant.GetProjectAccessRequired(), + AuthorizationRequired: projectToGrant.GetAuthorizationRequired(), + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + GrantedOrganizationId: gu.Ptr(grantedOrg.GetOrganizationId()), + GrantedOrganizationName: gu.Ptr(grantedOrgName), + GrantedState: 1, + } +} + +func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationResponse, actual *filter.PaginationResponse) { + assert.Equal(t, expected.AppliedLimit, actual.AppliedLimit) + assert.Equal(t, expected.TotalResult, actual.TotalResult) +} + +func TestServer_ListProjectGrants(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + dep func(*project.ListProjectGrantsRequest, *project.ListProjectGrantsResponse) + req *project.ListProjectGrantsRequest + } + tests := []struct { + name string + args args + want *project.ListProjectGrantsResponse + wantErr bool + }{{ + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), + }, + } + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}, {}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), + }, + } + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}, {}}, + }, + }, + wantErr: true, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{ + {Filter: &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{"notfound"}, + }, + }, + }, + }, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list by id", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instance, t, orgID, projectResp.GetId(), name) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, + }, + }, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + + createProjectGrant(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), projectResp.GetId(), name) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{}, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + + response.ProjectGrants[2] = createProjectGrant(iamOwnerCtx, instance, t, orgID, projectResp.GetId(), name) + response.ProjectGrants[1] = createProjectGrant(iamOwnerCtx, instance, t, orgID, projectResp.GetId(), name) + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instance, t, orgID, projectResp.GetId(), name) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, limited permissions", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name1 := gofakeit.AppName() + name2 := gofakeit.AppName() + name3 := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + project1Resp := instance.CreateProject(iamOwnerCtx, t, orgID, name1, false, false) + project2Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) + project3Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, + }, + } + + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instance, t, orgID, project1Resp.GetId(), name1) + createProjectGrant(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), project2Resp.GetId(), name2) + createProjectGrant(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), project3Resp.GetId(), name3) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, + }, + }, + }, { + name: "list single id with role", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + projectRoleResp := addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instance, t, orgID, projectResp.GetId(), name, projectRoleResp.Key) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, + }, + }, + }, + { + name: "list single id with removed role", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + projectRoleResp := addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instance, t, orgID, projectResp.GetId(), name, projectRoleResp.Key) + + removeRoleResp := instance.RemoveProjectRole(iamOwnerCtx, t, projectResp.GetId(), projectRoleResp.Key) + response.ProjectGrants[0].GrantedRoleKeys = nil + response.ProjectGrants[0].ChangeDate = removeRoleResp.GetRemovalDate() + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.Projectv2Beta.ListProjectGrants(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr, "Error: "+listErr.Error()) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.ProjectGrants, len(tt.want.ProjectGrants)) { + for i := range tt.want.ProjectGrants { + assert.EqualExportedValues(ttt, tt.want.ProjectGrants[i], got.ProjectGrants[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + dep func(*project.ListProjectGrantsRequest, *project.ListProjectGrantsResponse) + req *project.ListProjectGrantsRequest + } + tests := []struct { + name string + args args + want *project.ListProjectGrantsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + grantedOrg := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), + }, + } + + instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}, {}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + grantedOrg := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ + ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), + }, + } + + instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}, {}}, + }, + }, + wantErr: true, + }, + { + name: "list by id", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgID := instancePermissionV2.DefaultOrg.GetId() + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgID, projectResp.GetId(), name) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, + }, + }, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + + createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), projectResp.GetId(), name) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{}, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name := gofakeit.AppName() + orgID := instancePermissionV2.DefaultOrg.GetId() + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, name, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{projectResp.GetId()}, + }, + } + + response.ProjectGrants[2] = createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgID, projectResp.GetId(), name) + response.ProjectGrants[1] = createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgID, projectResp.GetId(), name) + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgID, projectResp.GetId(), name) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, limited permissions", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name1 := gofakeit.AppName() + name2 := gofakeit.AppName() + name3 := gofakeit.AppName() + orgID := instancePermissionV2.DefaultOrg.GetId() + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + project1Resp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, name1, false, false) + project2Resp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) + project3Resp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, + }, + } + + response.ProjectGrants[0] = createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgID, project1Resp.GetId(), name1) + createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), project2Resp.GetId(), name2) + createProjectGrant(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), project3Resp.GetId(), name3) + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instancePermissionV2.Client.Projectv2Beta.ListProjectGrants(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr, "Error: "+listErr.Error()) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.ProjectGrants, len(tt.want.ProjectGrants)) { + for i := range tt.want.ProjectGrants { + assert.EqualExportedValues(ttt, tt.want.ProjectGrants[i], got.ProjectGrants[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func createProjectGrant(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, projectID, projectName string, roles ...string) *project.ProjectGrant { + grantedOrgName := gofakeit.AppName() + grantedOrg := instance.CreateOrganization(ctx, grantedOrgName, gofakeit.Email()) + projectGrantResp := instance.CreateProjectGrant(ctx, t, projectID, grantedOrg.GetOrganizationId(), roles...) + + return &project.ProjectGrant{ + OrganizationId: orgID, + CreationDate: projectGrantResp.GetCreationDate(), + ChangeDate: projectGrantResp.GetCreationDate(), + GrantedOrganizationId: grantedOrg.GetOrganizationId(), + GrantedOrganizationName: grantedOrgName, + ProjectId: projectID, + ProjectName: projectName, + State: 1, + GrantedRoleKeys: roles, + } +} + +func TestServer_ListProjectRoles(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + type args struct { + ctx context.Context + dep func(*project.ListProjectRolesRequest, *project.ListProjectRolesResponse) + req *project.ListProjectRolesRequest + } + tests := []struct { + name string + args args + want *project.ListProjectRolesResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{ + Filters: []*project.ProjectRoleSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{ + Filters: []*project.ProjectRoleSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &project.ListProjectRolesRequest{ + ProjectId: "notfound", + }, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, missing permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{}, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectRoles: []*project.ProjectRole{}, + }, + }, + { + name: "list single id", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + orgID := instance.DefaultOrg.GetId() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + response.ProjectRoles[0] = addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{}, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectRoles: []*project.ProjectRole{ + {}, + }, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + orgID := instance.DefaultOrg.GetId() + projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + response.ProjectRoles[2] = addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + response.ProjectRoles[1] = addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + response.ProjectRoles[0] = addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{}, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + ProjectRoles: []*project.ProjectRole{ + {}, {}, {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.Projectv2Beta.ListProjectRoles(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr, "Error: "+listErr.Error()) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.ProjectRoles, len(tt.want.ProjectRoles)) { + for i := range tt.want.ProjectRoles { + assert.EqualExportedValues(ttt, tt.want.ProjectRoles[i], got.ProjectRoles[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func TestServer_ListProjectRoles_PermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + dep func(*project.ListProjectRolesRequest, *project.ListProjectRolesResponse) + req *project.ListProjectRolesRequest + } + tests := []struct { + name string + args args + want *project.ListProjectRolesResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + addProjectRole(iamOwnerCtx, instancePermissionV2, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{ + Filters: []*project.ProjectRoleSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + addProjectRole(iamOwnerCtx, instancePermissionV2, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{ + Filters: []*project.ProjectRoleSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &project.ListProjectRolesRequest{ + ProjectId: "notfound", + }, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, missing permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + addProjectRole(iamOwnerCtx, instancePermissionV2, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{}, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + ProjectRoles: []*project.ProjectRole{}, + }, + }, + { + name: "list single id", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + orgID := instancePermissionV2.DefaultOrg.GetId() + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + response.ProjectRoles[0] = addProjectRole(iamOwnerCtx, instancePermissionV2, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{}, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + ProjectRoles: []*project.ProjectRole{ + {}, + }, + }, + }, + { + name: "list multiple id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *project.ListProjectRolesRequest, response *project.ListProjectRolesResponse) { + orgID := instancePermissionV2.DefaultOrg.GetId() + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, gofakeit.AppName(), false, false) + + request.ProjectId = projectResp.GetId() + response.ProjectRoles[2] = addProjectRole(iamOwnerCtx, instancePermissionV2, t, projectResp.GetId()) + response.ProjectRoles[1] = addProjectRole(iamOwnerCtx, instancePermissionV2, t, projectResp.GetId()) + response.ProjectRoles[0] = addProjectRole(iamOwnerCtx, instancePermissionV2, t, projectResp.GetId()) + }, + req: &project.ListProjectRolesRequest{}, + }, + want: &project.ListProjectRolesResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + ProjectRoles: []*project.ProjectRole{ + {}, {}, {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instancePermissionV2.Client.Projectv2Beta.ListProjectRoles(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr, "Error: "+listErr.Error()) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.ProjectRoles, len(tt.want.ProjectRoles)) { + for i := range tt.want.ProjectRoles { + assert.EqualExportedValues(ttt, tt.want.ProjectRoles[i], got.ProjectRoles[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func addProjectRole(ctx context.Context, instance *integration.Instance, t *testing.T, projectID string) *project.ProjectRole { + name := gofakeit.Animal() + projectRoleResp := instance.AddProjectRole(ctx, t, projectID, name, name, name) + + return &project.ProjectRole{ + ProjectId: projectID, + CreationDate: projectRoleResp.GetCreationDate(), + ChangeDate: projectRoleResp.GetCreationDate(), + Key: name, + DisplayName: name, + Group: name, + } +} diff --git a/internal/api/grpc/project/v2beta/integration/server_test.go b/internal/api/grpc/project/v2beta/integration/server_test.go new file mode 100644 index 0000000000..59d9745222 --- /dev/null +++ b/internal/api/grpc/project/v2beta/integration/server_test.go @@ -0,0 +1,63 @@ +//go:build integration + +package project_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" +) + +var ( + CTX context.Context + instance *integration.Instance + instancePermissionV2 *integration.Instance +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + CTX = ctx + instance = integration.NewInstance(ctx) + instancePermissionV2 = integration.NewInstance(CTX) + return m.Run() + }()) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + assert.NoError(ttt, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + }, + retryDuration, + tick, + "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/project/v2beta/project.go b/internal/api/grpc/project/v2beta/project.go new file mode 100644 index 0000000000..01b478f5be --- /dev/null +++ b/internal/api/grpc/project/v2beta/project.go @@ -0,0 +1,161 @@ +package project + +import ( + "context" + + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func (s *Server) CreateProject(ctx context.Context, req *project_pb.CreateProjectRequest) (*project_pb.CreateProjectResponse, error) { + add := projectCreateToCommand(req) + project, err := s.command.AddProject(ctx, add) + if err != nil { + return nil, err + } + var creationDate *timestamppb.Timestamp + if !project.EventDate.IsZero() { + creationDate = timestamppb.New(project.EventDate) + } + return &project_pb.CreateProjectResponse{ + Id: add.AggregateID, + CreationDate: creationDate, + }, nil +} + +func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddProject { + var aggregateID string + if req.Id != nil { + aggregateID = *req.Id + } + return &command.AddProject{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: req.OrganizationId, + AggregateID: aggregateID, + }, + Name: req.Name, + ProjectRoleAssertion: req.ProjectRoleAssertion, + ProjectRoleCheck: req.AuthorizationRequired, + HasProjectCheck: req.ProjectAccessRequired, + PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting), + } +} + +func privateLabelingSettingToDomain(setting project_pb.PrivateLabelingSetting) domain.PrivateLabelingSetting { + switch setting { + case project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY: + return domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy + case project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ENFORCE_PROJECT_RESOURCE_OWNER_POLICY: + return domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy + case project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED: + return domain.PrivateLabelingSettingUnspecified + default: + return domain.PrivateLabelingSettingUnspecified + } +} + +func (s *Server) UpdateProject(ctx context.Context, req *project_pb.UpdateProjectRequest) (*project_pb.UpdateProjectResponse, error) { + project, err := s.command.ChangeProject(ctx, projectUpdateToCommand(req)) + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !project.EventDate.IsZero() { + changeDate = timestamppb.New(project.EventDate) + } + return &project_pb.UpdateProjectResponse{ + ChangeDate: changeDate, + }, nil +} + +func projectUpdateToCommand(req *project_pb.UpdateProjectRequest) *command.ChangeProject { + var labeling *domain.PrivateLabelingSetting + if req.PrivateLabelingSetting != nil { + labeling = gu.Ptr(privateLabelingSettingToDomain(*req.PrivateLabelingSetting)) + } + return &command.ChangeProject{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.Id, + }, + Name: req.Name, + ProjectRoleAssertion: req.ProjectRoleAssertion, + ProjectRoleCheck: req.ProjectRoleCheck, + HasProjectCheck: req.HasProjectCheck, + PrivateLabelingSetting: labeling, + } +} + +func (s *Server) DeleteProject(ctx context.Context, req *project_pb.DeleteProjectRequest) (*project_pb.DeleteProjectResponse, error) { + userGrantIDs, err := s.userGrantsFromProject(ctx, req.Id) + if err != nil { + return nil, err + } + + deletedAt, err := s.command.DeleteProject(ctx, req.Id, "", userGrantIDs...) + if err != nil { + return nil, err + } + var deletionDate *timestamppb.Timestamp + if !deletedAt.IsZero() { + deletionDate = timestamppb.New(deletedAt) + } + return &project_pb.DeleteProjectResponse{ + DeletionDate: deletionDate, + }, nil +} + +func (s *Server) userGrantsFromProject(ctx context.Context, projectID string) ([]string, error) { + projectQuery, err := query.NewUserGrantProjectIDSearchQuery(projectID) + if err != nil { + return nil, err + } + userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ + Queries: []query.SearchQuery{projectQuery}, + }, false) + if err != nil { + return nil, err + } + return userGrantsToIDs(userGrants.UserGrants), nil +} + +func (s *Server) DeactivateProject(ctx context.Context, req *project_pb.DeactivateProjectRequest) (*project_pb.DeactivateProjectResponse, error) { + details, err := s.command.DeactivateProject(ctx, req.Id, "") + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + changeDate = timestamppb.New(details.EventDate) + } + return &project_pb.DeactivateProjectResponse{ + ChangeDate: changeDate, + }, nil +} + +func (s *Server) ActivateProject(ctx context.Context, req *project_pb.ActivateProjectRequest) (*project_pb.ActivateProjectResponse, error) { + details, err := s.command.ReactivateProject(ctx, req.Id, "") + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + changeDate = timestamppb.New(details.EventDate) + } + return &project_pb.ActivateProjectResponse{ + ChangeDate: changeDate, + }, nil +} + +func userGrantsToIDs(userGrants []*query.UserGrant) []string { + converted := make([]string, len(userGrants)) + for i, grant := range userGrants { + converted[i] = grant.ID + } + return converted +} diff --git a/internal/api/grpc/project/v2beta/project_grant.go b/internal/api/grpc/project/v2beta/project_grant.go new file mode 100644 index 0000000000..c1c20d9cbc --- /dev/null +++ b/internal/api/grpc/project/v2beta/project_grant.go @@ -0,0 +1,126 @@ +package project + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func (s *Server) CreateProjectGrant(ctx context.Context, req *project_pb.CreateProjectGrantRequest) (*project_pb.CreateProjectGrantResponse, error) { + add := projectGrantCreateToCommand(req) + project, err := s.command.AddProjectGrant(ctx, add) + if err != nil { + return nil, err + } + var creationDate *timestamppb.Timestamp + if !project.EventDate.IsZero() { + creationDate = timestamppb.New(project.EventDate) + } + return &project_pb.CreateProjectGrantResponse{ + CreationDate: creationDate, + }, nil +} + +func projectGrantCreateToCommand(req *project_pb.CreateProjectGrantRequest) *command.AddProjectGrant { + return &command.AddProjectGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.ProjectId, + }, + GrantID: req.GrantedOrganizationId, + GrantedOrgID: req.GrantedOrganizationId, + RoleKeys: req.RoleKeys, + } +} + +func (s *Server) UpdateProjectGrant(ctx context.Context, req *project_pb.UpdateProjectGrantRequest) (*project_pb.UpdateProjectGrantResponse, error) { + project, err := s.command.ChangeProjectGrant(ctx, projectGrantUpdateToCommand(req)) + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !project.EventDate.IsZero() { + changeDate = timestamppb.New(project.EventDate) + } + return &project_pb.UpdateProjectGrantResponse{ + ChangeDate: changeDate, + }, nil +} + +func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *command.ChangeProjectGrant { + return &command.ChangeProjectGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.ProjectId, + }, + GrantID: req.GrantedOrganizationId, + RoleKeys: req.RoleKeys, + } +} + +func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.DeactivateProjectGrantRequest) (*project_pb.DeactivateProjectGrantResponse, error) { + details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId, "") + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + changeDate = timestamppb.New(details.EventDate) + } + return &project_pb.DeactivateProjectGrantResponse{ + ChangeDate: changeDate, + }, nil +} + +func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.ActivateProjectGrantRequest) (*project_pb.ActivateProjectGrantResponse, error) { + details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId, "") + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + changeDate = timestamppb.New(details.EventDate) + } + return &project_pb.ActivateProjectGrantResponse{ + ChangeDate: changeDate, + }, nil +} + +func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteProjectGrantRequest) (*project_pb.DeleteProjectGrantResponse, error) { + userGrantIDs, err := s.userGrantsFromProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId) + if err != nil { + return nil, err + } + details, err := s.command.RemoveProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId, "", userGrantIDs...) + if err != nil { + return nil, err + } + var deletionDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + deletionDate = timestamppb.New(details.EventDate) + } + return &project_pb.DeleteProjectGrantResponse{ + DeletionDate: deletionDate, + }, nil +} + +func (s *Server) userGrantsFromProjectGrant(ctx context.Context, projectID, grantedOrganizationID string) ([]string, error) { + projectQuery, err := query.NewUserGrantProjectIDSearchQuery(projectID) + if err != nil { + return nil, err + } + grantQuery, err := query.NewUserGrantGrantIDSearchQuery(grantedOrganizationID) + if err != nil { + return nil, err + } + userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ + Queries: []query.SearchQuery{projectQuery, grantQuery}, + }, false) + if err != nil { + return nil, err + } + return userGrantsToIDs(userGrants.UserGrants), nil +} diff --git a/internal/api/grpc/project/v2beta/project_role.go b/internal/api/grpc/project/v2beta/project_role.go new file mode 100644 index 0000000000..07fc4e9eac --- /dev/null +++ b/internal/api/grpc/project/v2beta/project_role.go @@ -0,0 +1,132 @@ +package project + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func (s *Server) AddProjectRole(ctx context.Context, req *project_pb.AddProjectRoleRequest) (*project_pb.AddProjectRoleResponse, error) { + role, err := s.command.AddProjectRole(ctx, addProjectRoleRequestToCommand(req)) + if err != nil { + return nil, err + } + var creationDate *timestamppb.Timestamp + if !role.EventDate.IsZero() { + creationDate = timestamppb.New(role.EventDate) + } + return &project_pb.AddProjectRoleResponse{ + CreationDate: creationDate, + }, nil +} + +func addProjectRoleRequestToCommand(req *project_pb.AddProjectRoleRequest) *command.AddProjectRole { + group := "" + if req.Group != nil { + group = *req.Group + } + + return &command.AddProjectRole{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.ProjectId, + }, + Key: req.RoleKey, + DisplayName: req.DisplayName, + Group: group, + } +} + +func (s *Server) UpdateProjectRole(ctx context.Context, req *project_pb.UpdateProjectRoleRequest) (*project_pb.UpdateProjectRoleResponse, error) { + role, err := s.command.ChangeProjectRole(ctx, updateProjectRoleRequestToCommand(req)) + if err != nil { + return nil, err + } + var changeDate *timestamppb.Timestamp + if !role.EventDate.IsZero() { + changeDate = timestamppb.New(role.EventDate) + } + return &project_pb.UpdateProjectRoleResponse{ + ChangeDate: changeDate, + }, nil +} + +func updateProjectRoleRequestToCommand(req *project_pb.UpdateProjectRoleRequest) *command.ChangeProjectRole { + displayName := "" + if req.DisplayName != nil { + displayName = *req.DisplayName + } + group := "" + if req.Group != nil { + group = *req.Group + } + + return &command.ChangeProjectRole{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.ProjectId, + }, + Key: req.RoleKey, + DisplayName: displayName, + Group: group, + } +} + +func (s *Server) RemoveProjectRole(ctx context.Context, req *project_pb.RemoveProjectRoleRequest) (*project_pb.RemoveProjectRoleResponse, error) { + userGrantIDs, err := s.userGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) + if err != nil { + return nil, err + } + projectGrantIDs, err := s.projectGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) + if err != nil { + return nil, err + } + details, err := s.command.RemoveProjectRole(ctx, req.ProjectId, req.RoleKey, "", projectGrantIDs, userGrantIDs...) + if err != nil { + return nil, err + } + var deletionDate *timestamppb.Timestamp + if !details.EventDate.IsZero() { + deletionDate = timestamppb.New(details.EventDate) + } + return &project_pb.RemoveProjectRoleResponse{ + RemovalDate: deletionDate, + }, nil +} + +func (s *Server) userGrantsFromProjectAndRole(ctx context.Context, projectID, roleKey string) ([]string, error) { + projectQuery, err := query.NewUserGrantProjectIDSearchQuery(projectID) + if err != nil { + return nil, err + } + rolesQuery, err := query.NewUserGrantRoleQuery(roleKey) + if err != nil { + return nil, err + } + userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ + Queries: []query.SearchQuery{projectQuery, rolesQuery}, + }, false) + if err != nil { + return nil, err + } + return userGrantsToIDs(userGrants.UserGrants), nil +} + +func (s *Server) projectGrantsFromProjectAndRole(ctx context.Context, projectID, roleKey string) ([]string, error) { + projectGrants, err := s.query.SearchProjectGrantsByProjectIDAndRoleKey(ctx, projectID, roleKey) + if err != nil { + return nil, err + } + return projectGrantsToIDs(projectGrants), nil +} + +func projectGrantsToIDs(projectGrants *query.ProjectGrants) []string { + converted := make([]string, len(projectGrants.ProjectGrants)) + for i, grant := range projectGrants.ProjectGrants { + converted[i] = grant.GrantID + } + return converted +} diff --git a/internal/api/grpc/project/v2beta/query.go b/internal/api/grpc/project/v2beta/query.go new file mode 100644 index 0000000000..1cdf9eefbd --- /dev/null +++ b/internal/api/grpc/project/v2beta/query.go @@ -0,0 +1,427 @@ +package project + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +func (s *Server) GetProject(ctx context.Context, req *project_pb.GetProjectRequest) (*project_pb.GetProjectResponse, error) { + project, err := s.query.GetProjectByIDWithPermission(ctx, true, req.Id, s.checkPermission) + if err != nil { + return nil, err + } + return &project_pb.GetProjectResponse{ + Project: projectToPb(project), + }, nil +} + +func (s *Server) ListProjects(ctx context.Context, req *project_pb.ListProjectsRequest) (*project_pb.ListProjectsResponse, error) { + queries, err := s.listProjectRequestToModel(req) + if err != nil { + return nil, err + } + resp, err := s.query.SearchGrantedProjects(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return &project_pb.ListProjectsResponse{ + Projects: grantedProjectsToPb(resp.GrantedProjects), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }, nil +} + +func (s *Server) listProjectRequestToModel(req *project_pb.ListProjectsRequest) (*query.ProjectAndGrantedProjectSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := projectFiltersToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.ProjectAndGrantedProjectSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: grantedProjectFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func grantedProjectFieldNameToSortingColumn(field *project_pb.ProjectFieldName) query.Column { + if field == nil { + return query.GrantedProjectColumnCreationDate + } + switch *field { + case project_pb.ProjectFieldName_PROJECT_FIELD_NAME_CREATION_DATE: + return query.GrantedProjectColumnCreationDate + case project_pb.ProjectFieldName_PROJECT_FIELD_NAME_ID: + return query.GrantedProjectColumnID + case project_pb.ProjectFieldName_PROJECT_FIELD_NAME_NAME: + return query.GrantedProjectColumnName + case project_pb.ProjectFieldName_PROJECT_FIELD_NAME_CHANGE_DATE: + return query.GrantedProjectColumnChangeDate + case project_pb.ProjectFieldName_PROJECT_FIELD_NAME_UNSPECIFIED: + return query.GrantedProjectColumnCreationDate + default: + return query.GrantedProjectColumnCreationDate + } +} + +func projectFiltersToQuery(queries []*project_pb.ProjectSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, qry := range queries { + q[i], err = projectFilterToModel(qry) + if err != nil { + return nil, err + } + } + return q, nil +} + +func projectFilterToModel(filter *project_pb.ProjectSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *project_pb.ProjectSearchFilter_ProjectNameFilter: + return projectNameFilterToQuery(q.ProjectNameFilter) + case *project_pb.ProjectSearchFilter_InProjectIdsFilter: + return projectInIDsFilterToQuery(q.InProjectIdsFilter) + case *project_pb.ProjectSearchFilter_ProjectResourceOwnerFilter: + return projectResourceOwnerFilterToQuery(q.ProjectResourceOwnerFilter) + case *project_pb.ProjectSearchFilter_ProjectOrganizationIdFilter: + return projectOrganizationIDFilterToQuery(q.ProjectOrganizationIdFilter) + case *project_pb.ProjectSearchFilter_ProjectGrantResourceOwnerFilter: + return projectGrantResourceOwnerFilterToQuery(q.ProjectGrantResourceOwnerFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ORG-vR9nC", "List.Query.Invalid") + } +} + +func projectNameFilterToQuery(q *project_pb.ProjectNameFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectNameSearchQuery(filter.TextMethodPbToQuery(q.Method), q.GetProjectName()) +} + +func projectInIDsFilterToQuery(q *project_pb.InProjectIDsFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectIDSearchQuery(q.ProjectIds) +} + +func projectResourceOwnerFilterToQuery(q *project_pb.ProjectResourceOwnerFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectResourceOwnerSearchQuery(q.ProjectResourceOwner) +} + +func projectOrganizationIDFilterToQuery(q *project_pb.ProjectOrganizationIDFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectOrganizationIDSearchQuery(q.ProjectOrganizationId) +} + +func projectGrantResourceOwnerFilterToQuery(q *project_pb.ProjectGrantResourceOwnerFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectGrantResourceOwnerSearchQuery(q.ProjectGrantResourceOwner) +} + +func grantedProjectsToPb(projects []*query.GrantedProject) []*project_pb.Project { + o := make([]*project_pb.Project, len(projects)) + for i, org := range projects { + o[i] = grantedProjectToPb(org) + } + return o +} + +func projectToPb(project *query.Project) *project_pb.Project { + return &project_pb.Project{ + Id: project.ID, + OrganizationId: project.ResourceOwner, + CreationDate: timestamppb.New(project.CreationDate), + ChangeDate: timestamppb.New(project.ChangeDate), + State: projectStateToPb(project.State), + Name: project.Name, + PrivateLabelingSetting: privateLabelingSettingToPb(project.PrivateLabelingSetting), + ProjectAccessRequired: project.HasProjectCheck, + ProjectRoleAssertion: project.ProjectRoleAssertion, + AuthorizationRequired: project.ProjectRoleCheck, + } +} + +func grantedProjectToPb(project *query.GrantedProject) *project_pb.Project { + var grantedOrganizationID, grantedOrganizationName *string + if project.GrantedOrgID != "" { + grantedOrganizationID = &project.GrantedOrgID + } + if project.OrgName != "" { + grantedOrganizationName = &project.OrgName + } + + return &project_pb.Project{ + Id: project.ProjectID, + OrganizationId: project.ResourceOwner, + CreationDate: timestamppb.New(project.CreationDate), + ChangeDate: timestamppb.New(project.ChangeDate), + State: projectStateToPb(project.ProjectState), + Name: project.ProjectName, + PrivateLabelingSetting: privateLabelingSettingToPb(project.PrivateLabelingSetting), + ProjectAccessRequired: project.HasProjectCheck, + ProjectRoleAssertion: project.ProjectRoleAssertion, + AuthorizationRequired: project.ProjectRoleCheck, + GrantedOrganizationId: grantedOrganizationID, + GrantedOrganizationName: grantedOrganizationName, + GrantedState: grantedProjectStateToPb(project.ProjectGrantState), + } +} + +func projectStateToPb(state domain.ProjectState) project_pb.ProjectState { + switch state { + case domain.ProjectStateActive: + return project_pb.ProjectState_PROJECT_STATE_ACTIVE + case domain.ProjectStateInactive: + return project_pb.ProjectState_PROJECT_STATE_INACTIVE + case domain.ProjectStateUnspecified, domain.ProjectStateRemoved: + return project_pb.ProjectState_PROJECT_STATE_UNSPECIFIED + default: + return project_pb.ProjectState_PROJECT_STATE_UNSPECIFIED + } +} +func grantedProjectStateToPb(state domain.ProjectGrantState) project_pb.GrantedProjectState { + switch state { + case domain.ProjectGrantStateActive: + return project_pb.GrantedProjectState_GRANTED_PROJECT_STATE_ACTIVE + case domain.ProjectGrantStateInactive: + return project_pb.GrantedProjectState_GRANTED_PROJECT_STATE_INACTIVE + case domain.ProjectGrantStateUnspecified, domain.ProjectGrantStateRemoved: + return project_pb.GrantedProjectState_GRANTED_PROJECT_STATE_UNSPECIFIED + default: + return project_pb.GrantedProjectState_GRANTED_PROJECT_STATE_UNSPECIFIED + } +} + +func privateLabelingSettingToPb(setting domain.PrivateLabelingSetting) project_pb.PrivateLabelingSetting { + switch setting { + case domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy: + return project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY + case domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy: + return project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ENFORCE_PROJECT_RESOURCE_OWNER_POLICY + case domain.PrivateLabelingSettingUnspecified: + return project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED + default: + return project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED + } +} + +func (s *Server) ListProjectGrants(ctx context.Context, req *project_pb.ListProjectGrantsRequest) (*project_pb.ListProjectGrantsResponse, error) { + queries, err := s.listProjectGrantsRequestToModel(req) + if err != nil { + return nil, err + } + resp, err := s.query.SearchProjectGrants(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return &project_pb.ListProjectGrantsResponse{ + ProjectGrants: projectGrantsToPb(resp.ProjectGrants), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }, nil +} + +func (s *Server) listProjectGrantsRequestToModel(req *project_pb.ListProjectGrantsRequest) (*query.ProjectGrantSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := projectGrantFiltersToModel(req.Filters) + if err != nil { + return nil, err + } + return &query.ProjectGrantSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: projectGrantFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func projectGrantFieldNameToSortingColumn(field *project_pb.ProjectGrantFieldName) query.Column { + if field == nil { + return query.ProjectGrantColumnCreationDate + } + switch *field { + case project_pb.ProjectGrantFieldName_PROJECT_GRANT_FIELD_NAME_PROJECT_ID: + return query.ProjectGrantColumnProjectID + case project_pb.ProjectGrantFieldName_PROJECT_GRANT_FIELD_NAME_CREATION_DATE: + return query.ProjectGrantColumnCreationDate + case project_pb.ProjectGrantFieldName_PROJECT_GRANT_FIELD_NAME_CHANGE_DATE: + return query.ProjectGrantColumnChangeDate + case project_pb.ProjectGrantFieldName_PROJECT_GRANT_FIELD_NAME_UNSPECIFIED: + return query.ProjectGrantColumnCreationDate + default: + return query.ProjectGrantColumnCreationDate + } +} + +func projectGrantFiltersToModel(queries []*project_pb.ProjectGrantSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, qry := range queries { + q[i], err = projectGrantFilterToModel(qry) + if err != nil { + return nil, err + } + } + return q, nil +} + +func projectGrantFilterToModel(filter *project_pb.ProjectGrantSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *project_pb.ProjectGrantSearchFilter_ProjectNameFilter: + return projectNameFilterToQuery(q.ProjectNameFilter) + case *project_pb.ProjectGrantSearchFilter_RoleKeyFilter: + return query.NewProjectGrantRoleKeySearchQuery(q.RoleKeyFilter.Key) + case *project_pb.ProjectGrantSearchFilter_InProjectIdsFilter: + return query.NewProjectGrantProjectIDsSearchQuery(q.InProjectIdsFilter.ProjectIds) + case *project_pb.ProjectGrantSearchFilter_ProjectResourceOwnerFilter: + return query.NewProjectGrantResourceOwnerSearchQuery(q.ProjectResourceOwnerFilter.ProjectResourceOwner) + case *project_pb.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter: + return query.NewProjectGrantGrantedOrgIDSearchQuery(q.ProjectGrantResourceOwnerFilter.ProjectGrantResourceOwner) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-M099f", "List.Query.Invalid") + } +} + +func projectGrantsToPb(projects []*query.ProjectGrant) []*project_pb.ProjectGrant { + p := make([]*project_pb.ProjectGrant, len(projects)) + for i, project := range projects { + p[i] = projectGrantToPb(project) + } + return p +} + +func projectGrantToPb(project *query.ProjectGrant) *project_pb.ProjectGrant { + return &project_pb.ProjectGrant{ + OrganizationId: project.ResourceOwner, + CreationDate: timestamppb.New(project.CreationDate), + ChangeDate: timestamppb.New(project.ChangeDate), + GrantedOrganizationId: project.GrantedOrgID, + GrantedOrganizationName: project.OrgName, + GrantedRoleKeys: project.GrantedRoleKeys, + ProjectId: project.ProjectID, + ProjectName: project.ProjectName, + State: projectGrantStateToPb(project.State), + } +} + +func projectGrantStateToPb(state domain.ProjectGrantState) project_pb.ProjectGrantState { + switch state { + case domain.ProjectGrantStateActive: + return project_pb.ProjectGrantState_PROJECT_GRANT_STATE_ACTIVE + case domain.ProjectGrantStateInactive: + return project_pb.ProjectGrantState_PROJECT_GRANT_STATE_INACTIVE + case domain.ProjectGrantStateUnspecified, domain.ProjectGrantStateRemoved: + return project_pb.ProjectGrantState_PROJECT_GRANT_STATE_UNSPECIFIED + default: + return project_pb.ProjectGrantState_PROJECT_GRANT_STATE_UNSPECIFIED + } +} + +func (s *Server) ListProjectRoles(ctx context.Context, req *project_pb.ListProjectRolesRequest) (*project_pb.ListProjectRolesResponse, error) { + queries, err := s.listProjectRolesRequestToModel(req) + if err != nil { + return nil, err + } + err = queries.AppendProjectIDQuery(req.ProjectId) + if err != nil { + return nil, err + } + roles, err := s.query.SearchProjectRoles(ctx, true, queries, s.checkPermission) + if err != nil { + return nil, err + } + return &project_pb.ListProjectRolesResponse{ + ProjectRoles: roleViewsToPb(roles.ProjectRoles), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, roles.SearchResponse), + }, nil +} + +func (s *Server) listProjectRolesRequestToModel(req *project_pb.ListProjectRolesRequest) (*query.ProjectRoleSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := roleQueriesToModel(req.Filters) + if err != nil { + return nil, err + } + return &query.ProjectRoleSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: projectRoleFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func projectRoleFieldNameToSortingColumn(field *project_pb.ProjectRoleFieldName) query.Column { + if field == nil { + return query.ProjectRoleColumnCreationDate + } + switch *field { + case project_pb.ProjectRoleFieldName_PROJECT_ROLE_FIELD_NAME_KEY: + return query.ProjectRoleColumnKey + case project_pb.ProjectRoleFieldName_PROJECT_ROLE_FIELD_NAME_CREATION_DATE: + return query.ProjectRoleColumnCreationDate + case project_pb.ProjectRoleFieldName_PROJECT_ROLE_FIELD_NAME_CHANGE_DATE: + return query.ProjectRoleColumnChangeDate + case project_pb.ProjectRoleFieldName_PROJECT_ROLE_FIELD_NAME_UNSPECIFIED: + return query.ProjectRoleColumnCreationDate + default: + return query.ProjectRoleColumnCreationDate + } +} + +func roleQueriesToModel(queries []*project_pb.ProjectRoleSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = roleQueryToModel(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func roleQueryToModel(apiQuery *project_pb.ProjectRoleSearchFilter) (query.SearchQuery, error) { + switch q := apiQuery.Filter.(type) { + case *project_pb.ProjectRoleSearchFilter_RoleKeyFilter: + return query.NewProjectRoleKeySearchQuery(filter.TextMethodPbToQuery(q.RoleKeyFilter.Method), q.RoleKeyFilter.Key) + case *project_pb.ProjectRoleSearchFilter_DisplayNameFilter: + return query.NewProjectRoleDisplayNameSearchQuery(filter.TextMethodPbToQuery(q.DisplayNameFilter.Method), q.DisplayNameFilter.DisplayName) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-fms0e", "List.Query.Invalid") + } +} + +func roleViewsToPb(roles []*query.ProjectRole) []*project_pb.ProjectRole { + o := make([]*project_pb.ProjectRole, len(roles)) + for i, org := range roles { + o[i] = roleViewToPb(org) + } + return o +} + +func roleViewToPb(role *query.ProjectRole) *project_pb.ProjectRole { + return &project_pb.ProjectRole{ + ProjectId: role.ProjectID, + Key: role.Key, + CreationDate: timestamppb.New(role.CreationDate), + ChangeDate: timestamppb.New(role.ChangeDate), + DisplayName: role.DisplayName, + Group: role.Group, + } +} diff --git a/internal/api/grpc/project/v2beta/server.go b/internal/api/grpc/project/v2beta/server.go new file mode 100644 index 0000000000..fe197f9688 --- /dev/null +++ b/internal/api/grpc/project/v2beta/server.go @@ -0,0 +1,60 @@ +package project + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +var _ project.ProjectServiceServer = (*Server)(nil) + +type Server struct { + project.UnimplementedProjectServiceServer + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + systemDefaults systemdefaults.SystemDefaults, + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + systemDefaults: systemDefaults, + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + project.RegisterProjectServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return project.ProjectService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return project.ProjectService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return project.ProjectService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return project.RegisterProjectServiceHandler +} diff --git a/internal/api/grpc/saml/v2/integration/saml_test.go b/internal/api/grpc/saml/v2/integration/saml_test.go index 1f227ab149..1974c5236a 100644 --- a/internal/api/grpc/saml/v2/integration/saml_test.go +++ b/internal/api/grpc/saml/v2/integration/saml_test.go @@ -332,7 +332,7 @@ func TestServer_CreateResponse_Permission(t *testing.T) { projectID2, _, _ := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID2, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID2, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) @@ -346,7 +346,7 @@ func TestServer_CreateResponse_Permission(t *testing.T) { projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) Instance.CreateProjectUserGrant(t, ctx, projectID, user.GetUserId()) @@ -368,9 +368,9 @@ func TestServer_CreateResponse_Permission(t *testing.T) { projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, true) orgResp := Instance.CreateOrganization(ctx, "saml-permission-"+gofakeit.AppName(), gofakeit.Email()) - projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) - Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, orgResp.GetOrganizationId(), user.GetUserId()) return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) }, @@ -502,9 +502,9 @@ func TestServer_CreateResponse_Permission(t *testing.T) { projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) - projectGrantResp := Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) - Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, projectGrantResp.GetGrantId(), user.GetUserId()) + Instance.CreateProjectGrantUserGrant(ctx, orgResp.GetOrganizationId(), projectID, orgResp.GetOrganizationId(), user.GetUserId()) return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) }, @@ -523,7 +523,7 @@ func TestServer_CreateResponse_Permission(t *testing.T) { projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, true, false) orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) @@ -563,7 +563,7 @@ func TestServer_CreateResponse_Permission(t *testing.T) { dep: func(ctx context.Context, t *testing.T) *saml_pb.CreateResponseRequest { projectID, _, sp := createSAMLApplication(ctx, t, idpMetadata, saml.HTTPRedirectBinding, false, true) orgResp := Instance.CreateOrganization(ctx, "saml-permissison-"+gofakeit.AppName(), gofakeit.Email()) - Instance.CreateProjectGrant(ctx, projectID, orgResp.GetOrganizationId()) + Instance.CreateProjectGrant(ctx, t, projectID, orgResp.GetOrganizationId()) user := Instance.CreateHumanUserVerified(ctx, orgResp.GetOrganizationId(), gofakeit.Email(), gofakeit.Phone()) return createSessionAndSmlRequestForCallback(ctx, t, sp, Instance.Users[integration.UserTypeOrgOwner].ID, acsRedirect, user.GetUserId(), saml.HTTPRedirectBinding) @@ -640,10 +640,9 @@ func createSAMLSP(t *testing.T, idpMetadata *saml.EntityDescriptor, binding stri } func createSAMLApplication(ctx context.Context, t *testing.T, idpMetadata *saml.EntityDescriptor, binding string, projectRoleCheck, hasProjectCheck bool) (string, string, *samlsp.Middleware) { - project, err := Instance.CreateProjectWithPermissionCheck(ctx, projectRoleCheck, hasProjectCheck) - require.NoError(t, err) + project := Instance.CreateProject(ctx, t, "", gofakeit.AppName(), projectRoleCheck, hasProjectCheck) rootURL, sp := createSAMLSP(t, idpMetadata, binding) - _, err = Instance.CreateSAMLClient(ctx, project.GetId(), sp) + _, err := Instance.CreateSAMLClient(ctx, project.GetId(), sp) require.NoError(t, err) return project.GetId(), rootURL, sp } diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index eaf352c094..832268dc8c 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -1753,8 +1753,8 @@ func TestServer_ReactivateUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - projectResp, err := Instance.CreateProject(CTX) - require.NoError(t, err) + projectResp := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) + type args struct { req *user.DeleteUserRequest prepare func(*testing.T, *user.DeleteUserRequest) context.Context diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index a5a1309d1a..250322d66f 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -1706,12 +1706,11 @@ func TestServer_ReactivateUser(t *testing.T) { } func TestServer_DeleteUser(t *testing.T) { - projectResp, err := Instance.CreateProject(CTX) - require.NoError(t, err) + projectResp := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) type args struct { ctx context.Context req *user.DeleteUserRequest - prepare func(request *user.DeleteUserRequest) error + prepare func(request *user.DeleteUserRequest) } tests := []struct { name string @@ -1726,7 +1725,7 @@ func TestServer_DeleteUser(t *testing.T) { &user.DeleteUserRequest{ UserId: "notexisting", }, - func(request *user.DeleteUserRequest) error { return nil }, + nil, }, wantErr: true, }, @@ -1735,10 +1734,9 @@ func TestServer_DeleteUser(t *testing.T) { args: args{ ctx: CTX, req: &user.DeleteUserRequest{}, - prepare: func(request *user.DeleteUserRequest) error { + prepare: func(request *user.DeleteUserRequest) { resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() - return err }, }, want: &user.DeleteUserResponse{ @@ -1753,10 +1751,9 @@ func TestServer_DeleteUser(t *testing.T) { args: args{ ctx: CTX, req: &user.DeleteUserRequest{}, - prepare: func(request *user.DeleteUserRequest) error { + prepare: func(request *user.DeleteUserRequest) { resp := Instance.CreateMachineUser(CTX) request.UserId = resp.GetUserId() - return err }, }, want: &user.DeleteUserResponse{ @@ -1771,13 +1768,12 @@ func TestServer_DeleteUser(t *testing.T) { args: args{ ctx: CTX, req: &user.DeleteUserRequest{}, - prepare: func(request *user.DeleteUserRequest) error { + prepare: func(request *user.DeleteUserRequest) { resp := Instance.CreateHumanUser(CTX) request.UserId = resp.GetUserId() Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateOrgMembership(t, CTX, request.UserId) - return err }, }, want: &user.DeleteUserResponse{ @@ -1790,8 +1786,9 @@ func TestServer_DeleteUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := tt.args.prepare(tt.args.req) - require.NoError(t, err) + if tt.args.prepare != nil { + tt.args.prepare(tt.args.req) + } got, err := Client.DeleteUser(tt.args.ctx, tt.args.req) if tt.wantErr { diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 66da6e3ccf..2f2880efc2 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -122,7 +122,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke if err != nil { return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := s.query.SearchProjectRoles(ctx, authz.GetFeatures(ctx).TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}) + roles, err := s.query.SearchProjectRoles(ctx, authz.GetFeatures(ctx).TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) if err != nil { return err } diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index f750b2a3ea..953ebe799c 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -459,7 +459,7 @@ func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string if err != nil { return nil, zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}) + roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) if err != nil { return nil, err } @@ -482,7 +482,7 @@ func (o *OPStorage) assertProjectRoleScopesByProject(ctx context.Context, projec if err != nil { return nil, zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}) + roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) if err != nil { return nil, err } @@ -498,7 +498,7 @@ func (o *OPStorage) assertClientScopesForPAT(ctx context.Context, token *model.T if err != nil { return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}) + roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) if err != nil { return err } diff --git a/internal/api/oidc/integration_test/client_test.go b/internal/api/oidc/integration_test/client_test.go index 43b000108c..08c17f69cb 100644 --- a/internal/api/oidc/integration_test/client_test.go +++ b/internal/api/oidc/integration_test/client_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/pkg/client" @@ -24,8 +25,7 @@ import ( ) func TestServer_Introspect(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) app, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false) require.NoError(t, err) @@ -188,10 +188,8 @@ func assertIntrospection( // with clients that have different authentication methods. func TestServer_VerifyClient(t *testing.T) { sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId()) - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) - projectInactive, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) + projectInactive := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) inactiveClient, err := Instance.CreateOIDCInactivateClient(CTX, redirectURI, logoutRedirectURI, project.GetId()) require.NoError(t, err) diff --git a/internal/api/oidc/integration_test/oidc_test.go b/internal/api/oidc/integration_test/oidc_test.go index 2ab78b972e..8bb103d0eb 100644 --- a/internal/api/oidc/integration_test/oidc_test.go +++ b/internal/api/oidc/integration_test/oidc_test.go @@ -421,21 +421,20 @@ type clientOpts struct { func createClientWithOpts(t testing.TB, instance *integration.Instance, opts clientOpts) (clientID, projectID string) { ctx := instance.WithAuthorization(CTX, integration.UserTypeOrgOwner) - project, err := instance.CreateProject(ctx) - require.NoError(t, err) + project := instance.CreateProject(ctx, t.(*testing.T), "", gofakeit.AppName(), false, false) app, err := instance.CreateOIDCClientLoginVersion(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, opts.devMode, opts.LoginVersion) require.NoError(t, err) return app.GetClientId(), project.GetId() } func createImplicitClient(t testing.TB) string { - app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, nil) + app, err := Instance.CreateOIDCImplicitFlowClient(CTX, t.(*testing.T), redirectURIImplicit, nil) require.NoError(t, err) return app.GetClientId() } func createImplicitClientNoLoginClientHeader(t testing.TB) string { - app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit, &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}}) + app, err := Instance.CreateOIDCImplicitFlowClient(CTX, t.(*testing.T), redirectURIImplicit, &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: nil}}}) require.NoError(t, err) return app.GetClientId() } diff --git a/internal/api/oidc/integration_test/token_device_test.go b/internal/api/oidc/integration_test/token_device_test.go index 0c6a65e8a2..9692909205 100644 --- a/internal/api/oidc/integration_test/token_device_test.go +++ b/internal/api/oidc/integration_test/token_device_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/pkg/client/rp" @@ -21,8 +22,7 @@ import ( ) func TestServer_DeviceAuth(t *testing.T) { - project, err := Instance.CreateProject(CTX) - require.NoError(t, err) + project := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) client, err := Instance.CreateOIDCClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), app.OIDCAppType_OIDC_APP_TYPE_NATIVE, app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, false, app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE) require.NoError(t, err) diff --git a/internal/api/oidc/integration_test/token_exchange_test.go b/internal/api/oidc/integration_test/token_exchange_test.go index 0844898a2f..dcd2d61669 100644 --- a/internal/api/oidc/integration_test/token_exchange_test.go +++ b/internal/api/oidc/integration_test/token_exchange_test.go @@ -147,7 +147,7 @@ func TestServer_TokenExchange(t *testing.T) { ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) userResp := instance.CreateHumanUser(ctx) - client, keyData, err := instance.CreateOIDCTokenExchangeClient(ctx) + client, keyData, err := instance.CreateOIDCTokenExchangeClient(ctx, t) require.NoError(t, err) signer, err := rp.SignerFromKeyFile(keyData)() require.NoError(t, err) @@ -371,7 +371,7 @@ func TestServer_TokenExchangeImpersonation(t *testing.T) { setTokenExchangeFeature(t, instance, true) setImpersonationPolicy(t, instance, true) - client, keyData, err := instance.CreateOIDCTokenExchangeClient(ctx) + client, keyData, err := instance.CreateOIDCTokenExchangeClient(ctx, t) require.NoError(t, err) signer, err := rp.SignerFromKeyFile(keyData)() require.NoError(t, err) @@ -591,7 +591,7 @@ func TestImpersonation_API_Call(t *testing.T) { instance := integration.NewInstance(CTX) ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - client, keyData, err := instance.CreateOIDCTokenExchangeClient(ctx) + client, keyData, err := instance.CreateOIDCTokenExchangeClient(ctx, t) require.NoError(t, err) signer, err := rp.SignerFromKeyFile(keyData)() require.NoError(t, err) diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index da1dc6b1e3..d4aded0b48 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -309,9 +309,7 @@ func TestServer_UserInfo_Issue6662(t *testing.T) { roleBar = "bar" ) - project, err := Instance.CreateProject(CTX) - projectID := project.GetId() - require.NoError(t, err) + projectID := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false).GetId() user, _, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX) require.NoError(t, err) addProjectRolesGrants(t, user.GetUserId(), projectID, roleFoo, roleBar) diff --git a/internal/api/scim/integration_test/users_delete_test.go b/internal/api/scim/integration_test/users_delete_test.go index 86e88f46c4..23c335f93c 100644 --- a/internal/api/scim/integration_test/users_delete_test.go +++ b/internal/api/scim/integration_test/users_delete_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" @@ -76,13 +77,12 @@ func TestDeleteUser_errors(t *testing.T) { func TestDeleteUser_ensureReallyDeleted(t *testing.T) { // create user and dependencies createUserResp := Instance.CreateHumanUser(CTX) - proj, err := Instance.CreateProject(CTX) - require.NoError(t, err) + proj := Instance.CreateProject(CTX, t, "", gofakeit.AppName(), false, false) Instance.CreateProjectUserGrant(t, CTX, proj.Id, createUserResp.UserId) // delete user via scim - err = Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId) + err := Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, createUserResp.UserId) assert.NoError(t, err) // ensure it is really deleted => try to delete again => should 404 diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index 0ede13ae68..7c335a752f 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -125,7 +125,7 @@ type userGrantProvider interface { type projectProvider interface { ProjectByClientID(context.Context, string) (*query.Project, error) - SearchProjectGrants(ctx context.Context, queries *query.ProjectGrantSearchQueries) (projects *query.ProjectGrants, err error) + SearchProjectGrants(ctx context.Context, queries *query.ProjectGrantSearchQueries, permissionCheck domain.PermissionCheck) (projects *query.ProjectGrants, err error) } type applicationProvider interface { @@ -1841,7 +1841,7 @@ func projectRequired(ctx context.Context, request *domain.AuthRequest, projectPr if err != nil { return false, err } - grants, err := projectProvider.SearchProjectGrants(ctx, &query.ProjectGrantSearchQueries{Queries: []query.SearchQuery{projectID, grantedOrg}}) + grants, err := projectProvider.SearchProjectGrants(ctx, &query.ProjectGrantSearchQueries{Queries: []query.SearchQuery{projectID, grantedOrg}}, nil) if err != nil { return false, err } diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go index 7d71ddecd9..78edd2a7e4 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request_test.go @@ -286,7 +286,7 @@ func (m *mockProject) ProjectByClientID(ctx context.Context, s string) (*query.P return &query.Project{ResourceOwner: m.resourceOwner, HasProjectCheck: m.projectCheck}, nil } -func (m *mockProject) SearchProjectGrants(ctx context.Context, queries *query.ProjectGrantSearchQueries) (*query.ProjectGrants, error) { +func (m *mockProject) SearchProjectGrants(ctx context.Context, queries *query.ProjectGrantSearchQueries, permissionCheck domain.PermissionCheck) (*query.ProjectGrants, error) { if m.hasProject { mockProjectGrant := new(query.ProjectGrant) return &query.ProjectGrants{ProjectGrants: []*query.ProjectGrant{mockProjectGrant}}, nil diff --git a/internal/command/org.go b/internal/command/org.go index 9874410a5f..b6650ef7f2 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -468,7 +468,7 @@ func (c *Commands) prepareRemoveOrg(a *org.Aggregate) preparation.Validation { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMA-wG9p1", "Errors.Org.DefaultOrgNotDeletable") } - err := c.checkProjectExists(ctx, instance.ProjectID(), a.ID) + _, err := c.checkProjectExists(ctx, instance.ProjectID(), a.ID) // if there is no error, the ZITADEL project was found on the org to be deleted if err == nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMA-AF3JW", "Errors.Org.ZitadelOrgNotDeletable") diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index bec2e9b7d4..253b6ee72a 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/v2/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -58,3 +59,15 @@ func (c *Commands) checkPermissionUpdateUser(ctx context.Context, resourceOwner, func (c *Commands) checkPermissionUpdateUserCredentials(ctx context.Context, resourceOwner, userID string) error { return c.checkPermissionOnUser(ctx, domain.PermissionUserCredentialWrite)(resourceOwner, userID) } + +func (c *Commands) checkPermissionDeleteProject(ctx context.Context, resourceOwner, projectID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectDelete, project.AggregateType)(resourceOwner, projectID) +} + +func (c *Commands) checkPermissionUpdateProject(ctx context.Context, resourceOwner, projectID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectWrite, project.AggregateType)(resourceOwner, projectID) +} + +func (c *Commands) checkPermissionWriteProjectGrant(ctx context.Context, resourceOwner, projectGrantID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectGrantWrite, project.AggregateType)(resourceOwner, projectGrantID) +} diff --git a/internal/command/project.go b/internal/command/project.go index df4f8ab545..bf72306417 100644 --- a/internal/command/project.go +++ b/internal/command/project.go @@ -3,6 +3,7 @@ package command import ( "context" "strings" + "time" "github.com/zitadel/logging" @@ -10,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/repository/project" @@ -17,67 +19,60 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddProjectWithID(ctx context.Context, project *domain.Project, resourceOwner, projectID string) (_ *domain.Project, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - if resourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-w8tnSoJxtn", "Errors.ResourceOwnerMissing") - } - if projectID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-nDXf5vXoUj", "Errors.IDMissing") - } - if !project.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-IOVCC", "Errors.Project.Invalid") - } - project, err = c.addProjectWithID(ctx, project, resourceOwner, projectID) - if err != nil { - return nil, err - } - return project, nil +type AddProject struct { + models.ObjectRoot + + Name string + ProjectRoleAssertion bool + ProjectRoleCheck bool + HasProjectCheck bool + PrivateLabelingSetting domain.PrivateLabelingSetting } -func (c *Commands) AddProject(ctx context.Context, project *domain.Project, resourceOwner string) (_ *domain.Project, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - if !project.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-IOVCC", "Errors.Project.Invalid") +func (p *AddProject) IsValid() error { + if p.ResourceOwner == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-fmq7bqQX1s", "Errors.ResourceOwnerMissing") } - if resourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-fmq7bqQX1s", "Errors.ResourceOwnerMissing") + if p.Name == "" { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-IOVCC", "Errors.Project.Invalid") } - - projectID, err := c.idGenerator.Next() - if err != nil { - return nil, err - } - - project, err = c.addProjectWithID(ctx, project, resourceOwner, projectID) - if err != nil { - return nil, err - } - return project, nil + return nil } -func (c *Commands) addProjectWithID(ctx context.Context, projectAdd *domain.Project, resourceOwner, projectID string) (_ *domain.Project, err error) { - projectAdd.AggregateID = projectID - projectWriteModel, err := c.getProjectWriteModelByID(ctx, projectAdd.AggregateID, resourceOwner) +func (c *Commands) AddProject(ctx context.Context, add *AddProject) (_ *domain.ObjectDetails, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err := add.IsValid(); err != nil { + return nil, err + } + + if add.AggregateID == "" { + add.AggregateID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } + } + wm, err := c.getProjectWriteModelByID(ctx, add.AggregateID, add.ResourceOwner) if err != nil { return nil, err } - if isProjectStateExists(projectWriteModel.State) { + if isProjectStateExists(wm.State) { return nil, zerrors.ThrowAlreadyExists(nil, "COMMAND-opamwu", "Errors.Project.AlreadyExisting") } + if err := c.checkPermissionUpdateProject(ctx, wm.ResourceOwner, wm.AggregateID); err != nil { + return nil, err + } events := []eventstore.Command{ project.NewProjectAddedEvent( ctx, - //nolint: contextcheck - ProjectAggregateFromWriteModel(&projectWriteModel.WriteModel), - projectAdd.Name, - projectAdd.ProjectRoleAssertion, - projectAdd.ProjectRoleCheck, - projectAdd.HasProjectCheck, - projectAdd.PrivateLabelingSetting), + ProjectAggregateFromWriteModelWithCTX(ctx, &wm.WriteModel), + add.Name, + add.ProjectRoleAssertion, + add.ProjectRoleCheck, + add.HasProjectCheck, + add.PrivateLabelingSetting), } postCommit, err := c.projectCreatedMilestone(ctx, &events) if err != nil { @@ -88,11 +83,11 @@ func (c *Commands) addProjectWithID(ctx context.Context, projectAdd *domain.Proj return nil, err } postCommit(ctx) - err = AppendAndReduce(projectWriteModel, pushedEvents...) + err = AppendAndReduce(wm, pushedEvents...) if err != nil { return nil, err } - return projectWriteModelToProject(projectWriteModel), nil + return writeModelToObjectDetails(&wm.WriteModel), nil } func AddProjectCommand( @@ -143,14 +138,14 @@ func projectWriteModel(ctx context.Context, filter preparation.FilterToQueryRedu return project, nil } -func (c *Commands) projectAggregateByID(ctx context.Context, projectID, resourceOwner string) (*eventstore.Aggregate, domain.ProjectState, error) { - result, err := c.projectState(ctx, projectID, resourceOwner) +func (c *Commands) projectAggregateByID(ctx context.Context, projectID string) (*eventstore.Aggregate, domain.ProjectState, error) { + result, err := c.projectState(ctx, projectID) if err != nil { return nil, domain.ProjectStateUnspecified, zerrors.ThrowNotFound(err, "COMMA-NDQoF", "Errors.Project.NotFound") } if len(result) == 0 { _ = projection.ProjectGrantFields.Trigger(ctx) - result, err = c.projectState(ctx, projectID, resourceOwner) + result, err = c.projectState(ctx, projectID) if err != nil || len(result) == 0 { return nil, domain.ProjectStateUnspecified, zerrors.ThrowNotFound(err, "COMMA-U1nza", "Errors.Project.NotFound") } @@ -164,7 +159,7 @@ func (c *Commands) projectAggregateByID(ctx context.Context, projectID, resource return &result[0].Aggregate, state, nil } -func (c *Commands) projectState(ctx context.Context, projectID, resourceOwner string) ([]*eventstore.SearchResult, error) { +func (c *Commands) projectState(ctx context.Context, projectID string) ([]*eventstore.SearchResult, error) { return c.eventstore.Search( ctx, map[eventstore.FieldType]any{ @@ -172,12 +167,11 @@ func (c *Commands) projectState(ctx context.Context, projectID, resourceOwner st eventstore.FieldTypeObjectID: projectID, eventstore.FieldTypeObjectRevision: project.ProjectObjectRevision, eventstore.FieldTypeFieldName: project.ProjectStateSearchField, - eventstore.FieldTypeResourceOwner: resourceOwner, }, ) } -func (c *Commands) checkProjectExists(ctx context.Context, projectID, resourceOwner string) (err error) { +func (c *Commands) checkProjectExists(ctx context.Context, projectID, resourceOwner string) (_ string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -185,50 +179,69 @@ func (c *Commands) checkProjectExists(ctx context.Context, projectID, resourceOw return c.checkProjectExistsOld(ctx, projectID, resourceOwner) } - _, state, err := c.projectAggregateByID(ctx, projectID, resourceOwner) + agg, state, err := c.projectAggregateByID(ctx, projectID) if err != nil || !state.Valid() { - return zerrors.ThrowPreconditionFailed(err, "COMMA-VCnwD", "Errors.Project.NotFound") + return "", zerrors.ThrowPreconditionFailed(err, "COMMA-VCnwD", "Errors.Project.NotFound") + } + return agg.ResourceOwner, nil +} + +type ChangeProject struct { + models.ObjectRoot + + Name *string + ProjectRoleAssertion *bool + ProjectRoleCheck *bool + HasProjectCheck *bool + PrivateLabelingSetting *domain.PrivateLabelingSetting +} + +func (p *ChangeProject) IsValid() error { + if p.AggregateID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.Invalid") + } + if p.Name != nil && *p.Name == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.Invalid") } return nil } -func (c *Commands) ChangeProject(ctx context.Context, projectChange *domain.Project, resourceOwner string) (*domain.Project, error) { - if !projectChange.IsValid() || projectChange.AggregateID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.Invalid") +func (c *Commands) ChangeProject(ctx context.Context, change *ChangeProject) (_ *domain.ObjectDetails, err error) { + if err := change.IsValid(); err != nil { + return nil, err } - existingProject, err := c.getProjectWriteModelByID(ctx, projectChange.AggregateID, resourceOwner) + existing, err := c.getProjectWriteModelByID(ctx, change.AggregateID, change.ResourceOwner) if err != nil { return nil, err } - if !isProjectStateExists(existingProject.State) { + if !isProjectStateExists(existing.State) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.NotFound") } + if err := c.checkPermissionUpdateProject(ctx, existing.ResourceOwner, existing.AggregateID); err != nil { + return nil, err + } - projectAgg := ProjectAggregateFromWriteModel(&existingProject.WriteModel) - changedEvent, hasChanged, err := existingProject.NewChangedEvent( + changedEvent := existing.NewChangedEvent( ctx, - projectAgg, - projectChange.Name, - projectChange.ProjectRoleAssertion, - projectChange.ProjectRoleCheck, - projectChange.HasProjectCheck, - projectChange.PrivateLabelingSetting) + ProjectAggregateFromWriteModelWithCTX(ctx, &existing.WriteModel), + change.Name, + change.ProjectRoleAssertion, + change.ProjectRoleCheck, + change.HasProjectCheck, + change.PrivateLabelingSetting) + if changedEvent == nil { + return writeModelToObjectDetails(&existing.WriteModel), nil + } + err = c.pushAppendAndReduce(ctx, existing, changedEvent) if err != nil { return nil, err } - if !hasChanged { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2M0fs", "Errors.NoChangesFound") - } - err = c.pushAppendAndReduce(ctx, existingProject, changedEvent) - if err != nil { - return nil, err - } - return projectWriteModelToProject(existingProject), nil + return writeModelToObjectDetails(&existing.WriteModel), nil } func (c *Commands) DeactivateProject(ctx context.Context, projectID string, resourceOwner string) (*domain.ObjectDetails, error) { - if projectID == "" || resourceOwner == "" { + if projectID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-88iF0", "Errors.Project.ProjectIDMissing") } @@ -236,7 +249,7 @@ func (c *Commands) DeactivateProject(ctx context.Context, projectID string, reso return c.deactivateProjectOld(ctx, projectID, resourceOwner) } - projectAgg, state, err := c.projectAggregateByID(ctx, projectID, resourceOwner) + projectAgg, state, err := c.projectAggregateByID(ctx, projectID) if err != nil { return nil, err } @@ -247,6 +260,9 @@ func (c *Commands) DeactivateProject(ctx context.Context, projectID string, reso if state != domain.ProjectStateActive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-mki55", "Errors.Project.NotActive") } + if err := c.checkPermissionUpdateProject(ctx, projectAgg.ResourceOwner, projectAgg.ID); err != nil { + return nil, err + } pushedEvents, err := c.eventstore.Push(ctx, project.NewProjectDeactivatedEvent(ctx, projectAgg)) if err != nil { @@ -261,7 +277,7 @@ func (c *Commands) DeactivateProject(ctx context.Context, projectID string, reso } func (c *Commands) ReactivateProject(ctx context.Context, projectID string, resourceOwner string) (*domain.ObjectDetails, error) { - if projectID == "" || resourceOwner == "" { + if projectID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-3ihsF", "Errors.Project.ProjectIDMissing") } @@ -269,7 +285,7 @@ func (c *Commands) ReactivateProject(ctx context.Context, projectID string, reso return c.reactivateProjectOld(ctx, projectID, resourceOwner) } - projectAgg, state, err := c.projectAggregateByID(ctx, projectID, resourceOwner) + projectAgg, state, err := c.projectAggregateByID(ctx, projectID) if err != nil { return nil, err } @@ -280,6 +296,9 @@ func (c *Commands) ReactivateProject(ctx context.Context, projectID string, reso if state != domain.ProjectStateInactive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5M9bs", "Errors.Project.NotInactive") } + if err := c.checkPermissionUpdateProject(ctx, projectAgg.ResourceOwner, projectAgg.ID); err != nil { + return nil, err + } pushedEvents, err := c.eventstore.Push(ctx, project.NewProjectReactivatedEvent(ctx, projectAgg)) if err != nil { @@ -293,6 +312,7 @@ func (c *Commands) ReactivateProject(ctx context.Context, projectID string, reso }, nil } +// Deprecated: use commands.DeleteProject func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner string, cascadingUserGrantIDs ...string) (*domain.ObjectDetails, error) { if projectID == "" || resourceOwner == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-66hM9", "Errors.Project.ProjectIDMissing") @@ -316,15 +336,18 @@ func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner s uniqueConstraints[i] = project.NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID.EntityID) } - projectAgg := ProjectAggregateFromWriteModel(&existingProject.WriteModel) events := []eventstore.Command{ - project.NewProjectRemovedEvent(ctx, projectAgg, existingProject.Name, uniqueConstraints), + project.NewProjectRemovedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingProject.WriteModel), + existingProject.Name, + uniqueConstraints, + ), } for _, grantID := range cascadingUserGrantIDs { event, _, err := c.removeUserGrant(ctx, grantID, "", true) if err != nil { - logging.LogWithFields("COMMAND-b8Djf", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") + logging.WithFields("id", "COMMAND-b8Djf", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue } events = append(events, event) @@ -341,6 +364,53 @@ func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner s return writeModelToObjectDetails(&existingProject.WriteModel), nil } +func (c *Commands) DeleteProject(ctx context.Context, id, resourceOwner string, cascadingUserGrantIDs ...string) (time.Time, error) { + if id == "" { + return time.Time{}, zerrors.ThrowInvalidArgument(nil, "COMMAND-obqos2l3no", "Errors.IDMissing") + } + + existing, err := c.getProjectWriteModelByID(ctx, id, resourceOwner) + if err != nil { + return time.Time{}, err + } + if !isProjectStateExists(existing.State) { + return existing.WriteModel.ChangeDate, nil + } + if err := c.checkPermissionDeleteProject(ctx, existing.ResourceOwner, existing.AggregateID); err != nil { + return time.Time{}, err + } + + samlEntityIDsAgg, err := c.getSAMLEntityIdsWriteModelByProjectID(ctx, id, resourceOwner) + if err != nil { + return time.Time{}, err + } + + uniqueConstraints := make([]*eventstore.UniqueConstraint, len(samlEntityIDsAgg.EntityIDs)) + for i, entityID := range samlEntityIDsAgg.EntityIDs { + uniqueConstraints[i] = project.NewRemoveSAMLConfigEntityIDUniqueConstraint(entityID.EntityID) + } + events := []eventstore.Command{ + project.NewProjectRemovedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existing.WriteModel), + existing.Name, + uniqueConstraints, + ), + } + for _, grantID := range cascadingUserGrantIDs { + event, _, err := c.removeUserGrant(ctx, grantID, "", true) + if err != nil { + logging.WithFields("id", "COMMAND-b8Djf", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") + continue + } + events = append(events, event) + } + + if err := c.pushAppendAndReduce(ctx, existing, events...); err != nil { + return time.Time{}, err + } + return existing.WriteModel.ChangeDate, nil +} + func (c *Commands) getProjectWriteModelByID(ctx context.Context, projectID, resourceOwner string) (_ *ProjectWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index 21c3bc5ee7..5b8bbafcdf 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -79,7 +79,7 @@ func (c *Commands) AddAPIApplicationWithID(ctx context.Context, apiApp *domain.A return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-mabu12", "Errors.Project.App.AlreadyExisting") } - if err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + if _, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { return nil, err } return c.addAPIApplicationWithID(ctx, apiApp, resourceOwner, appID) @@ -90,7 +90,7 @@ func (c *Commands) AddAPIApplication(ctx context.Context, apiApp *domain.APIApp, return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-5m9E", "Errors.Project.App.Invalid") } - if err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + if _, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { return nil, err } if !apiApp.IsValid() { diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index fccb0efe06..491bd38fca 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -132,7 +132,7 @@ func (c *Commands) AddOIDCApplicationWithID(ctx context.Context, oidcApp *domain return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-lxowmp", "Errors.Project.App.AlreadyExisting") } - if err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + if _, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { return nil, err } return c.addOIDCApplicationWithID(ctx, oidcApp, resourceOwner, appID) @@ -142,7 +142,7 @@ func (c *Commands) AddOIDCApplication(ctx context.Context, oidcApp *domain.OIDCA if oidcApp == nil || oidcApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-34Fm0", "Errors.Project.App.Invalid") } - if err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + if _, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { return nil, err } if oidcApp.AppName == "" || !oidcApp.IsValid() { diff --git a/internal/command/project_application_saml.go b/internal/command/project_application_saml.go index b14bed0758..1a5cefa221 100644 --- a/internal/command/project_application_saml.go +++ b/internal/command/project_application_saml.go @@ -16,7 +16,7 @@ func (c *Commands) AddSAMLApplication(ctx context.Context, application *domain.S return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-35Fn0", "Errors.Project.App.Invalid") } - if err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner); err != nil { + if _, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner); err != nil { return nil, err } addedApplication := NewSAMLApplicationWriteModel(application.AggregateID, resourceOwner) diff --git a/internal/command/project_grant.go b/internal/command/project_grant.go index 82d5dcab38..763ea7ab67 100644 --- a/internal/command/project_grant.go +++ b/internal/command/project_grant.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" @@ -16,69 +17,107 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddProjectGrantWithID(ctx context.Context, grant *domain.ProjectGrant, grantID string, resourceOwner string) (_ *domain.ProjectGrant, err error) { +type AddProjectGrant struct { + es_models.ObjectRoot + + GrantID string + GrantedOrgID string + RoleKeys []string +} + +func (p *AddProjectGrant) IsValid() error { + if p.AggregateID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-FYRnWEzBzV", "Errors.Project.Grant.Invalid") + } + if p.GrantedOrgID == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-PPhHpWGRAE", "Errors.Project.Grant.Invalid") + } + return nil +} + +func (c *Commands) AddProjectGrant(ctx context.Context, grant *AddProjectGrant) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - return c.addProjectGrantWithID(ctx, grant, grantID, resourceOwner) + if err := grant.IsValid(); err != nil { + return nil, err + } + + if grant.GrantID == "" { + grant.GrantID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } + } + + projectResourceOwner, err := c.checkProjectGrantPreCondition(ctx, grant.AggregateID, grant.GrantedOrgID, grant.ResourceOwner, grant.RoleKeys) + if err != nil { + return nil, err + } + // if there is no resourceowner provided then use the resourceowner of the project + if grant.ResourceOwner == "" { + grant.ResourceOwner = projectResourceOwner + } + if err := c.checkPermissionWriteProjectGrant(ctx, grant.ResourceOwner, grant.GrantID); err != nil { + return nil, err + } + + wm := NewProjectGrantWriteModel(grant.GrantID, grant.AggregateID, grant.ResourceOwner) + // error if provided resourceowner is not equal to the resourceowner of the project or the project grant is for the same organization + if projectResourceOwner != wm.ResourceOwner || wm.ResourceOwner == grant.GrantedOrgID { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-ckUpbvboAH", "Errors.Project.Grant.Invalid") + } + if err := c.pushAppendAndReduce(ctx, + wm, + project.NewGrantAddedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &wm.WriteModel), + grant.GrantID, + grant.GrantedOrgID, + grant.RoleKeys), + ); err != nil { + return nil, err + } + return writeModelToObjectDetails(&wm.WriteModel), nil } -func (c *Commands) AddProjectGrant(ctx context.Context, grant *domain.ProjectGrant, resourceOwner string) (_ *domain.ProjectGrant, err error) { - if !grant.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-3b8fs", "Errors.Project.Grant.Invalid") - } - err = c.checkProjectGrantPreCondition(ctx, grant, resourceOwner) - if err != nil { - return nil, err - } +type ChangeProjectGrant struct { + es_models.ObjectRoot - grantID, err := c.idGenerator.Next() - if err != nil { - return nil, err - } - - return c.addProjectGrantWithID(ctx, grant, grantID, resourceOwner) + GrantID string + RoleKeys []string } -func (c *Commands) addProjectGrantWithID(ctx context.Context, grant *domain.ProjectGrant, grantID string, resourceOwner string) (_ *domain.ProjectGrant, err error) { - grant.GrantID = grantID - - addedGrant := NewProjectGrantWriteModel(grant.GrantID, grant.AggregateID, resourceOwner) - projectAgg := ProjectAggregateFromWriteModel(&addedGrant.WriteModel) - pushedEvents, err := c.eventstore.Push( - ctx, - project.NewGrantAddedEvent(ctx, projectAgg, grant.GrantID, grant.GrantedOrgID, grant.RoleKeys)) - if err != nil { - return nil, err - } - err = AppendAndReduce(addedGrant, pushedEvents...) - if err != nil { - return nil, err - } - return projectGrantWriteModelToProjectGrant(addedGrant), nil -} - -func (c *Commands) ChangeProjectGrant(ctx context.Context, grant *domain.ProjectGrant, resourceOwner string, cascadeUserGrantIDs ...string) (_ *domain.ProjectGrant, err error) { +func (c *Commands) ChangeProjectGrant(ctx context.Context, grant *ChangeProjectGrant, cascadeUserGrantIDs ...string) (_ *domain.ObjectDetails, err error) { if grant.GrantID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1j83s", "Errors.IDMissing") } - existingGrant, err := c.projectGrantWriteModelByID(ctx, grant.GrantID, grant.AggregateID, resourceOwner) + existingGrant, err := c.projectGrantWriteModelByID(ctx, grant.GrantID, grant.AggregateID, grant.ResourceOwner) if err != nil { return nil, err } - grant.GrantedOrgID = existingGrant.GrantedOrgID - err = c.checkProjectGrantPreCondition(ctx, grant, resourceOwner) + if err := c.checkPermissionWriteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + return nil, err + } + projectResourceOwner, err := c.checkProjectGrantPreCondition(ctx, existingGrant.AggregateID, existingGrant.GrantedOrgID, existingGrant.ResourceOwner, grant.RoleKeys) if err != nil { return nil, err } - projectAgg := ProjectAggregateFromWriteModel(&existingGrant.WriteModel) + // error if provided resourceowner is not equal to the resourceowner of the project + if existingGrant.ResourceOwner != projectResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-q1BhA68RBC", "Errors.Project.Grant.Invalid") + } + // return if there are no changes to the project grant roles if reflect.DeepEqual(existingGrant.RoleKeys, grant.RoleKeys) { - return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-0o0pL", "Errors.NoChangesFoundc") + return writeModelToObjectDetails(&existingGrant.WriteModel), nil } events := []eventstore.Command{ - project.NewGrantChangedEvent(ctx, projectAgg, grant.GrantID, grant.RoleKeys), + project.NewGrantChangedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), + existingGrant.GrantID, + grant.RoleKeys, + ), } removedRoles := domain.GetRemovedRoles(existingGrant.RoleKeys, grant.RoleKeys) @@ -91,7 +130,7 @@ func (c *Commands) ChangeProjectGrant(ctx context.Context, grant *domain.Project if err != nil { return nil, err } - return projectGrantWriteModelToProjectGrant(existingGrant), nil + return writeModelToObjectDetails(&existingGrant.WriteModel), nil } for _, userGrantID := range cascadeUserGrantIDs { @@ -109,7 +148,7 @@ func (c *Commands) ChangeProjectGrant(ctx context.Context, grant *domain.Project if err != nil { return nil, err } - return projectGrantWriteModelToProjectGrant(existingGrant), nil + return writeModelToObjectDetails(&existingGrant.WriteModel), nil } func (c *Commands) removeRoleFromProjectGrant(ctx context.Context, projectAgg *eventstore.Aggregate, projectID, projectGrantID, roleKey string, cascade bool) (_ eventstore.Command, _ *ProjectGrantWriteModel, err error) { @@ -147,7 +186,7 @@ func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantI return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-p0s4V", "Errors.IDMissing") } - err = c.checkProjectExists(ctx, projectID, resourceOwner) + projectResourceOwner, err := c.checkProjectExists(ctx, projectID, resourceOwner) if err != nil { return nil, err } @@ -156,12 +195,27 @@ func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantI if err != nil { return details, err } + // error if provided resourceowner is not equal to the resourceowner of the project + if projectResourceOwner != existingGrant.ResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-0l10S9OmZV", "Errors.Project.Grant.Invalid") + } + // return if project grant is already inactive + if existingGrant.State == domain.ProjectGrantStateInactive { + return writeModelToObjectDetails(&existingGrant.WriteModel), nil + } + // error if project grant is neither active nor inactive if existingGrant.State != domain.ProjectGrantStateActive { return details, zerrors.ThrowPreconditionFailed(nil, "PROJECT-47fu8", "Errors.Project.Grant.NotActive") } - projectAgg := ProjectAggregateFromWriteModel(&existingGrant.WriteModel) - - pushedEvents, err := c.eventstore.Push(ctx, project.NewGrantDeactivateEvent(ctx, projectAgg, grantID)) + if err := c.checkPermissionWriteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, + project.NewGrantDeactivateEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), + grantID, + ), + ) if err != nil { return nil, err } @@ -177,7 +231,7 @@ func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantI return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-p0s4V", "Errors.IDMissing") } - err = c.checkProjectExists(ctx, projectID, resourceOwner) + projectResourceOwner, err := c.checkProjectExists(ctx, projectID, resourceOwner) if err != nil { return nil, err } @@ -186,11 +240,27 @@ func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantI if err != nil { return details, err } + // error if provided resourceowner is not equal to the resourceowner of the project + if projectResourceOwner != existingGrant.ResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-byscAarAST", "Errors.Project.Grant.Invalid") + } + // return if project grant is already active + if existingGrant.State == domain.ProjectGrantStateActive { + return writeModelToObjectDetails(&existingGrant.WriteModel), nil + } + // error if project grant is neither active nor inactive if existingGrant.State != domain.ProjectGrantStateInactive { return details, zerrors.ThrowPreconditionFailed(nil, "PROJECT-47fu8", "Errors.Project.Grant.NotInactive") } - projectAgg := ProjectAggregateFromWriteModel(&existingGrant.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, project.NewGrantReactivatedEvent(ctx, projectAgg, grantID)) + if err := c.checkPermissionWriteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, + project.NewGrantReactivatedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), + grantID, + ), + ) if err != nil { return nil, err } @@ -209,9 +279,20 @@ func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, r if err != nil { return details, err } + // return if project grant does not exist, or was removed already + if !existingGrant.State.Exists() { + return writeModelToObjectDetails(&existingGrant.WriteModel), nil + } + if err := c.checkPermissionDeleteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + return nil, err + } events := make([]eventstore.Command, 0) - projectAgg := ProjectAggregateFromWriteModel(&existingGrant.WriteModel) - events = append(events, project.NewGrantRemovedEvent(ctx, projectAgg, grantID, existingGrant.GrantedOrgID)) + events = append(events, project.NewGrantRemovedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), + grantID, + existingGrant.GrantedOrgID, + ), + ) for _, userGrantID := range cascadeUserGrantIDs { event, _, err := c.removeUserGrant(ctx, userGrantID, "", true) @@ -232,6 +313,10 @@ func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, r return writeModelToObjectDetails(&existingGrant.WriteModel), nil } +func (c *Commands) checkPermissionDeleteProjectGrant(ctx context.Context, resourceOwner, projectGrantID string) error { + return c.checkPermission(ctx, domain.PermissionProjectGrantDelete, resourceOwner, projectGrantID) +} + func (c *Commands) projectGrantWriteModelByID(ctx context.Context, grantID, projectID, resourceOwner string) (member *ProjectGrantWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -249,55 +334,61 @@ func (c *Commands) projectGrantWriteModelByID(ctx context.Context, grantID, proj return writeModel, nil } -func (c *Commands) checkProjectGrantPreCondition(ctx context.Context, projectGrant *domain.ProjectGrant, resourceOwner string) error { +func (c *Commands) checkProjectGrantPreCondition(ctx context.Context, projectID, grantedOrgID, resourceOwner string, roles []string) (string, error) { if !authz.GetFeatures(ctx).ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeProjectGrant) { - return c.checkProjectGrantPreConditionOld(ctx, projectGrant, resourceOwner) + return c.checkProjectGrantPreConditionOld(ctx, projectID, grantedOrgID, resourceOwner, roles) } - existingRoleKeys, err := c.searchProjectGrantState(ctx, projectGrant.AggregateID, projectGrant.GrantedOrgID, resourceOwner) + projectResourceOwner, existingRoleKeys, err := c.searchProjectGrantState(ctx, projectID, grantedOrgID, resourceOwner) if err != nil { - return err + return "", err } - if projectGrant.HasInvalidRoles(existingRoleKeys) { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-6m9gd", "Errors.Project.Role.NotFound") + if domain.HasInvalidRoles(existingRoleKeys, roles) { + return "", zerrors.ThrowPreconditionFailed(err, "COMMAND-6m9gd", "Errors.Project.Role.NotFound") } - return nil + return projectResourceOwner, nil } -func (c *Commands) searchProjectGrantState(ctx context.Context, projectID, grantedOrgID, resourceOwner string) (existingRoleKeys []string, err error) { +func (c *Commands) searchProjectGrantState(ctx context.Context, projectID, grantedOrgID, resourceOwner string) (_ string, existingRoleKeys []string, err error) { + projectStateQuery := map[eventstore.FieldType]any{ + eventstore.FieldTypeAggregateType: project.AggregateType, + eventstore.FieldTypeAggregateID: projectID, + eventstore.FieldTypeFieldName: project.ProjectStateSearchField, + eventstore.FieldTypeObjectType: project.ProjectSearchType, + } + grantedOrgQuery := map[eventstore.FieldType]any{ + eventstore.FieldTypeAggregateType: org.AggregateType, + eventstore.FieldTypeAggregateID: grantedOrgID, + eventstore.FieldTypeFieldName: org.OrgStateSearchField, + eventstore.FieldTypeObjectType: org.OrgSearchType, + } + roleQuery := map[eventstore.FieldType]any{ + eventstore.FieldTypeAggregateType: project.AggregateType, + eventstore.FieldTypeAggregateID: projectID, + eventstore.FieldTypeFieldName: project.ProjectRoleKeySearchField, + eventstore.FieldTypeObjectType: project.ProjectRoleSearchType, + } + + // as resourceowner is not always provided, it has to be separately + if resourceOwner != "" { + projectStateQuery[eventstore.FieldTypeResourceOwner] = resourceOwner + roleQuery[eventstore.FieldTypeResourceOwner] = resourceOwner + } + results, err := c.eventstore.Search( ctx, - // project state query - map[eventstore.FieldType]any{ - eventstore.FieldTypeResourceOwner: resourceOwner, - eventstore.FieldTypeAggregateType: project.AggregateType, - eventstore.FieldTypeAggregateID: projectID, - eventstore.FieldTypeFieldName: project.ProjectStateSearchField, - eventstore.FieldTypeObjectType: project.ProjectSearchType, - }, - // granted org query - map[eventstore.FieldType]any{ - eventstore.FieldTypeAggregateType: org.AggregateType, - eventstore.FieldTypeAggregateID: grantedOrgID, - eventstore.FieldTypeFieldName: org.OrgStateSearchField, - eventstore.FieldTypeObjectType: org.OrgSearchType, - }, - // role query - map[eventstore.FieldType]any{ - eventstore.FieldTypeResourceOwner: resourceOwner, - eventstore.FieldTypeAggregateType: project.AggregateType, - eventstore.FieldTypeAggregateID: projectID, - eventstore.FieldTypeFieldName: project.ProjectRoleKeySearchField, - eventstore.FieldTypeObjectType: project.ProjectRoleSearchType, - }, + projectStateQuery, + grantedOrgQuery, + roleQuery, ) if err != nil { - return nil, err + return "", nil, err } var ( - existsProject bool - existsGrantedOrg bool + existsProject bool + existingProjectResourceOwner string + existsGrantedOrg bool ) for _, result := range results { @@ -306,31 +397,32 @@ func (c *Commands) searchProjectGrantState(ctx context.Context, projectID, grant var role string err := result.Value.Unmarshal(&role) if err != nil { - return nil, err + return "", nil, err } existingRoleKeys = append(existingRoleKeys, role) case org.OrgSearchType: var state domain.OrgState err := result.Value.Unmarshal(&state) if err != nil { - return nil, err + return "", nil, err } existsGrantedOrg = state.Valid() && state != domain.OrgStateRemoved case project.ProjectSearchType: var state domain.ProjectState err := result.Value.Unmarshal(&state) if err != nil { - return nil, err + return "", nil, err } existsProject = state.Valid() && state != domain.ProjectStateRemoved + existingProjectResourceOwner = result.Aggregate.ResourceOwner } } if !existsProject { - return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-m9gsd", "Errors.Project.NotFound") + return "", nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-m9gsd", "Errors.Project.NotFound") } if !existsGrantedOrg { - return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-3m9gg", "Errors.Org.NotFound") + return "", nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-3m9gg", "Errors.Org.NotFound") } - return existingRoleKeys, nil + return existingProjectResourceOwner, existingRoleKeys, nil } diff --git a/internal/command/project_grant_model.go b/internal/command/project_grant_model.go index c658b00b69..a8c1fe2850 100644 --- a/internal/command/project_grant_model.go +++ b/internal/command/project_grant_model.go @@ -133,14 +133,18 @@ func (wm *ProjectGrantPreConditionReadModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { case *project.ProjectAddedEvent: - if e.Aggregate().ResourceOwner != wm.ResourceOwner { + if wm.ResourceOwner == "" { + wm.ResourceOwner = e.Aggregate().ResourceOwner + } + if wm.ResourceOwner != e.Aggregate().ResourceOwner { continue } wm.ProjectExists = true case *project.ProjectRemovedEvent: - if e.Aggregate().ResourceOwner != wm.ResourceOwner { + if wm.ResourceOwner != e.Aggregate().ResourceOwner { continue } + wm.ResourceOwner = "" wm.ProjectExists = false case *project.RoleAddedEvent: if e.Aggregate().ResourceOwner != wm.ResourceOwner { diff --git a/internal/command/project_grant_test.go b/internal/command/project_grant_test.go index e39835e2f4..f1befa0de2 100644 --- a/internal/command/project_grant_test.go +++ b/internal/command/project_grant_test.go @@ -19,16 +19,16 @@ import ( func TestCommandSide_AddProjectGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - projectGrant *domain.ProjectGrant - resourceOwner string + ctx context.Context + projectGrant *AddProjectGrant } type res struct { - want *domain.ProjectGrant + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -40,18 +40,18 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { { name: "invalid usergrant, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &AddProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, + GrantID: "grant1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -60,20 +60,21 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { { name: "project not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &AddProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, + GrantID: "grant1", GrantedOrgID: "grantedorg1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -82,8 +83,7 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { { name: "project not existing in org, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -108,16 +108,18 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &AddProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, + GrantID: "grant1", GrantedOrgID: "grantedorg1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -126,8 +128,7 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { { name: "granted org not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -138,16 +139,18 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &AddProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, + GrantID: "grant1", GrantedOrgID: "grantedorg1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -156,8 +159,7 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { { name: "project roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -174,27 +176,74 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &AddProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, + GrantID: "grant1", GrantedOrgID: "grantedorg1", RoleKeys: []string{"key1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, }, }, { - name: "usergrant for project, ok", + name: "grant for project, same resourceowner", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "granted org", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "key1", + "key", + "", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "projectgrant1"), + }, + args: args{ + ctx: context.Background(), + projectGrant: &AddProjectGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + ResourceOwner: "org1", + }, + GrantedOrgID: "org1", + RoleKeys: []string{"key1"}, + }, + }, + res: res{ + err: zerrors.IsPreconditionFailed, + }, + }, + { + name: "grant for project, ok", + fields: fields{ + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -227,21 +276,67 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "projectgrant1"), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "projectgrant1"), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &AddProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, GrantedOrgID: "grantedorg1", RoleKeys: []string{"key1"}, }, - resourceOwner: "org1", }, res: res{ - want: &domain.ProjectGrant{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "grant for project, id, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("grantedorg1").Aggregate, + "granted org", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "key1", + "key", + "", + ), + ), + ), + expectPush( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectGrant: &AddProjectGrant{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", ResourceOwner: "org1", @@ -249,7 +344,11 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { GrantID: "projectgrant1", GrantedOrgID: "grantedorg1", RoleKeys: []string{"key1"}, - State: domain.ProjectGrantStateActive, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, @@ -257,10 +356,11 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddProjectGrant(tt.args.ctx, tt.args.projectGrant, tt.args.resourceOwner) + got, err := r.AddProjectGrant(tt.args.ctx, tt.args.projectGrant) if tt.res.err == nil { assert.NoError(t, err) } @@ -268,7 +368,8 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assert.NotEmpty(t, got.ID) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -276,16 +377,16 @@ func TestCommandSide_AddProjectGrant(t *testing.T) { func TestCommandSide_ChangeProjectGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context - projectGrant *domain.ProjectGrant - resourceOwner string + projectGrant *ChangeProjectGrant cascadeUserGrantIDs []string } type res struct { - want *domain.ProjectGrant + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -297,18 +398,17 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { { name: "invalid projectgrant, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -317,22 +417,21 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { { name: "projectgrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, + GrantID: "projectgrant1", + RoleKeys: []string{"key1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -341,8 +440,7 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { { name: "project not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -353,17 +451,17 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", + GrantID: "projectgrant1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -372,8 +470,7 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { { name: "project not existing in org, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -398,18 +495,18 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, + GrantID: "projectgrant1", + RoleKeys: []string{"key1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -418,8 +515,7 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { { name: "granted org not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -438,17 +534,17 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", + GrantID: "projectgrant1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -457,8 +553,7 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { { name: "project roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -483,18 +578,18 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, + GrantID: "projectgrant1", + RoleKeys: []string{"key1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -503,8 +598,7 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { { name: "projectgrant not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -537,28 +631,29 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", + AggregateID: "project1", + ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, + GrantID: "projectgrant1", + RoleKeys: []string{"key1"}, }, - resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "projectgrant only added roles, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -606,37 +701,29 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1", "key2"}, - }, - resourceOwner: "org1", - }, - res: res{ - want: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1", "key2"}, - State: domain.ProjectGrantStateActive, + GrantID: "projectgrant1", + RoleKeys: []string{"key1", "key2"}, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, { name: "projectgrant remove roles, usergrant not found, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -685,38 +772,30 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, - }, - resourceOwner: "org1", - cascadeUserGrantIDs: []string{"usergrant1"}, - }, - res: res{ - want: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, - State: domain.ProjectGrantStateActive, + GrantID: "projectgrant1", + RoleKeys: []string{"key1"}, + }, + cascadeUserGrantIDs: []string{"usergrant1"}, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, { name: "projectgrant remove roles, usergrant not found, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -778,30 +857,23 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - projectGrant: &domain.ProjectGrant{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, - }, - resourceOwner: "org1", - cascadeUserGrantIDs: []string{"usergrant1"}, - }, - res: res{ - want: &domain.ProjectGrant{ + projectGrant: &ChangeProjectGrant{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", ResourceOwner: "org1", }, - GrantID: "projectgrant1", - GrantedOrgID: "grantedorg1", - RoleKeys: []string{"key1"}, - State: domain.ProjectGrantStateActive, + GrantID: "projectgrant1", + RoleKeys: []string{"key1"}, + }, + cascadeUserGrantIDs: []string{"usergrant1"}, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, @@ -809,9 +881,10 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeProjectGrant(tt.args.ctx, tt.args.projectGrant, tt.args.resourceOwner, tt.args.cascadeUserGrantIDs...) + got, err := r.ChangeProjectGrant(tt.args.ctx, tt.args.projectGrant, tt.args.cascadeUserGrantIDs...) if tt.res.err == nil { assert.NoError(t, err) } @@ -819,7 +892,7 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -827,7 +900,8 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { func TestCommandSide_DeactivateProjectGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -848,9 +922,8 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { { name: "missing projectid, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -864,9 +937,8 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { { name: "missing grantid, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -880,10 +952,10 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { { name: "project not existing, precondition failed error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -898,8 +970,7 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { { name: "projectgrant not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -911,6 +982,7 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -923,10 +995,9 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { }, }, { - name: "projectgrant already deactivated, precondition error", + name: "projectgrant already deactivated, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -949,6 +1020,7 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { )), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -957,14 +1029,15 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "projectgrant deactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -989,6 +1062,7 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1006,7 +1080,8 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.DeactivateProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.resourceOwner) if tt.res.err == nil { @@ -1024,7 +1099,8 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { func TestCommandSide_ReactivateProjectGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -1045,9 +1121,8 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { { name: "missing projectid, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1061,9 +1136,8 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { { name: "missing grantid, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1077,10 +1151,10 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { { name: "project not existing, precondition failed error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1095,8 +1169,7 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { { name: "projectgrant not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -1108,6 +1181,7 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1122,8 +1196,7 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { { name: "projectgrant not inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -1142,6 +1215,7 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { )), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1150,14 +1224,15 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "projectgrant reactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -1186,6 +1261,7 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1203,7 +1279,8 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.ReactivateProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.resourceOwner) if tt.res.err == nil { @@ -1221,7 +1298,8 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { func TestCommandSide_RemoveProjectGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -1243,9 +1321,8 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { { name: "missing projectid, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1259,9 +1336,8 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { { name: "missing grantid, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1275,8 +1351,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { { name: "project already removed, precondition failed error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -1293,6 +1368,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1307,10 +1383,10 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { { name: "projectgrant not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1325,8 +1401,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { { name: "projectgrant remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -1343,6 +1418,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1359,8 +1435,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { { name: "projectgrant remove, cascading usergrant not found, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -1378,6 +1453,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1395,8 +1471,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { { name: "projectgrant remove with cascading usergrants, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -1426,6 +1501,7 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1444,7 +1520,8 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.resourceOwner, tt.args.cascadeUserGrantIDs...) if tt.res.err == nil { diff --git a/internal/command/project_model.go b/internal/command/project_model.go index a46b07a8fe..cabceb8500 100644 --- a/internal/command/project_model.go +++ b/internal/command/project_model.go @@ -88,40 +88,35 @@ func (wm *ProjectWriteModel) Query() *eventstore.SearchQueryBuilder { func (wm *ProjectWriteModel) NewChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, - name string, + name *string, projectRoleAssertion, projectRoleCheck, - hasProjectCheck bool, - privateLabelingSetting domain.PrivateLabelingSetting, -) (*project.ProjectChangeEvent, bool, error) { + hasProjectCheck *bool, + privateLabelingSetting *domain.PrivateLabelingSetting, +) *project.ProjectChangeEvent { changes := make([]project.ProjectChanges, 0) - var err error oldName := "" - if wm.Name != name { + if name != nil && wm.Name != *name { oldName = wm.Name - changes = append(changes, project.ChangeName(name)) + changes = append(changes, project.ChangeName(*name)) } - if wm.ProjectRoleAssertion != projectRoleAssertion { - changes = append(changes, project.ChangeProjectRoleAssertion(projectRoleAssertion)) + if projectRoleAssertion != nil && wm.ProjectRoleAssertion != *projectRoleAssertion { + changes = append(changes, project.ChangeProjectRoleAssertion(*projectRoleAssertion)) } - if wm.ProjectRoleCheck != projectRoleCheck { - changes = append(changes, project.ChangeProjectRoleCheck(projectRoleCheck)) + if projectRoleCheck != nil && wm.ProjectRoleCheck != *projectRoleCheck { + changes = append(changes, project.ChangeProjectRoleCheck(*projectRoleCheck)) } - if wm.HasProjectCheck != hasProjectCheck { - changes = append(changes, project.ChangeHasProjectCheck(hasProjectCheck)) + if hasProjectCheck != nil && wm.HasProjectCheck != *hasProjectCheck { + changes = append(changes, project.ChangeHasProjectCheck(*hasProjectCheck)) } - if wm.PrivateLabelingSetting != privateLabelingSetting { - changes = append(changes, project.ChangePrivateLabelingSetting(privateLabelingSetting)) + if privateLabelingSetting != nil && wm.PrivateLabelingSetting != *privateLabelingSetting { + changes = append(changes, project.ChangePrivateLabelingSetting(*privateLabelingSetting)) } if len(changes) == 0 { - return nil, false, nil + return nil } - changeEvent, err := project.NewProjectChangeEvent(ctx, aggregate, oldName, changes) - if err != nil { - return nil, false, err - } - return changeEvent, true, nil + return project.NewProjectChangeEvent(ctx, aggregate, oldName, changes) } func isProjectStateExists(state domain.ProjectState) bool { @@ -132,6 +127,10 @@ func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggre return eventstore.AggregateFromWriteModel(wm, project.AggregateType, project.AggregateVersion) } +func ProjectAggregateFromWriteModelWithCTX(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return project.AggregateFromWriteModel(ctx, wm) +} + func hasProjectState(check domain.ProjectState, states ...domain.ProjectState) bool { for _, state := range states { if check == state { diff --git a/internal/command/project_old.go b/internal/command/project_old.go index 35ea9b3ebb..e1b6f02721 100644 --- a/internal/command/project_old.go +++ b/internal/command/project_old.go @@ -9,18 +9,18 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) checkProjectExistsOld(ctx context.Context, projectID, resourceOwner string) (err error) { +func (c *Commands) checkProjectExistsOld(ctx context.Context, projectID, resourceOwner string) (_ string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() projectWriteModel, err := c.getProjectWriteModelByID(ctx, projectID, resourceOwner) if err != nil { - return err + return "", err } if !isProjectStateExists(projectWriteModel.State) { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-EbFMN", "Errors.Project.NotFound") + return "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-EbFMN", "Errors.Project.NotFound") } - return nil + return projectWriteModel.ResourceOwner, nil } func (c *Commands) deactivateProjectOld(ctx context.Context, projectID string, resourceOwner string) (*domain.ObjectDetails, error) { @@ -34,6 +34,9 @@ func (c *Commands) deactivateProjectOld(ctx context.Context, projectID string, r if existingProject.State != domain.ProjectStateActive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-mki55", "Errors.Project.NotActive") } + if err := c.checkPermissionUpdateProject(ctx, existingProject.ResourceOwner, existingProject.AggregateID); err != nil { + return nil, err + } //nolint: contextcheck projectAgg := ProjectAggregateFromWriteModel(&existingProject.WriteModel) @@ -59,6 +62,9 @@ func (c *Commands) reactivateProjectOld(ctx context.Context, projectID string, r if existingProject.State != domain.ProjectStateInactive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5M9bs", "Errors.Project.NotInactive") } + if err := c.checkPermissionUpdateProject(ctx, existingProject.ResourceOwner, existingProject.AggregateID); err != nil { + return nil, err + } //nolint: contextcheck projectAgg := ProjectAggregateFromWriteModel(&existingProject.WriteModel) @@ -73,20 +79,20 @@ func (c *Commands) reactivateProjectOld(ctx context.Context, projectID string, r return writeModelToObjectDetails(&existingProject.WriteModel), nil } -func (c *Commands) checkProjectGrantPreConditionOld(ctx context.Context, projectGrant *domain.ProjectGrant, resourceOwner string) error { - preConditions := NewProjectGrantPreConditionReadModel(projectGrant.AggregateID, projectGrant.GrantedOrgID, resourceOwner) +func (c *Commands) checkProjectGrantPreConditionOld(ctx context.Context, projectID, grantedOrgID, resourceOwner string, roles []string) (string, error) { + preConditions := NewProjectGrantPreConditionReadModel(projectID, grantedOrgID, resourceOwner) err := c.eventstore.FilterToQueryReducer(ctx, preConditions) if err != nil { - return err + return "", err } if !preConditions.ProjectExists { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-m9gsd", "Errors.Project.NotFound") + return "", zerrors.ThrowPreconditionFailed(err, "COMMAND-m9gsd", "Errors.Project.NotFound") } if !preConditions.GrantedOrgExists { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-3m9gg", "Errors.Org.NotFound") + return "", zerrors.ThrowPreconditionFailed(err, "COMMAND-3m9gg", "Errors.Org.NotFound") } - if projectGrant.HasInvalidRoles(preConditions.ExistingRoleKeys) { - return zerrors.ThrowPreconditionFailed(err, "COMMAND-6m9gd", "Errors.Project.Role.NotFound") + if domain.HasInvalidRoles(preConditions.ExistingRoleKeys, roles) { + return "", zerrors.ThrowPreconditionFailed(err, "COMMAND-6m9gd", "Errors.Project.Role.NotFound") } - return nil + return preConditions.ResourceOwner, nil } diff --git a/internal/command/project_role.go b/internal/command/project_role.go index 3c8f9c725c..60d002b967 100644 --- a/internal/command/project_role.go +++ b/internal/command/project_role.go @@ -7,22 +7,45 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddProjectRole(ctx context.Context, projectRole *domain.ProjectRole, resourceOwner string) (_ *domain.ProjectRole, err error) { +type AddProjectRole struct { + models.ObjectRoot + + Key string + DisplayName string + Group string +} + +func (p *AddProjectRole) IsValid() bool { + return p.AggregateID != "" && p.Key != "" +} + +func (c *Commands) AddProjectRole(ctx context.Context, projectRole *AddProjectRole) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - err = c.checkProjectExists(ctx, projectRole.AggregateID, resourceOwner) + projectResourceOwner, err := c.checkProjectExists(ctx, projectRole.AggregateID, projectRole.ResourceOwner) if err != nil { return nil, err } + if projectRole.ResourceOwner == "" { + projectRole.ResourceOwner = projectResourceOwner + } + if err := c.checkPermissionWriteProjectRole(ctx, projectRole.ResourceOwner, projectRole.Key); err != nil { + return nil, err + } - roleWriteModel := NewProjectRoleWriteModelWithKey(projectRole.Key, projectRole.AggregateID, resourceOwner) - projectAgg := ProjectAggregateFromWriteModel(&roleWriteModel.WriteModel) + roleWriteModel := NewProjectRoleWriteModelWithKey(projectRole.Key, projectRole.AggregateID, projectRole.ResourceOwner) + if roleWriteModel.ResourceOwner != projectResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-RLB4UpqQSd", "Errors.Project.Role.Invalid") + } + + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &roleWriteModel.WriteModel) events, err := c.addProjectRoles(ctx, projectAgg, projectRole) if err != nil { return nil, err @@ -35,37 +58,45 @@ func (c *Commands) AddProjectRole(ctx context.Context, projectRole *domain.Proje if err != nil { return nil, err } - return roleWriteModelToRole(roleWriteModel), nil + return writeModelToObjectDetails(&roleWriteModel.WriteModel), nil } -func (c *Commands) BulkAddProjectRole(ctx context.Context, projectID, resourceOwner string, projectRoles []*domain.ProjectRole) (details *domain.ObjectDetails, err error) { - err = c.checkProjectExists(ctx, projectID, resourceOwner) +func (c *Commands) checkPermissionWriteProjectRole(ctx context.Context, resourceOwner, roleKey string) error { + return c.checkPermission(ctx, domain.PermissionProjectRoleWrite, resourceOwner, roleKey) +} + +func (c *Commands) BulkAddProjectRole(ctx context.Context, projectID, resourceOwner string, projectRoles []*AddProjectRole) (details *domain.ObjectDetails, err error) { + projectResourceOwner, err := c.checkProjectExists(ctx, projectID, resourceOwner) if err != nil { return nil, err } + for _, projectRole := range projectRoles { + if projectRole.ResourceOwner == "" { + projectRole.ResourceOwner = projectResourceOwner + } + if err := c.checkPermissionWriteProjectRole(ctx, projectRole.ResourceOwner, projectRole.Key); err != nil { + return nil, err + } + if projectRole.ResourceOwner != projectResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-9ZXtdaJKJJ", "Errors.Project.Role.Invalid") + } + } roleWriteModel := NewProjectRoleWriteModel(projectID, resourceOwner) - projectAgg := ProjectAggregateFromWriteModel(&roleWriteModel.WriteModel) + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &roleWriteModel.WriteModel) events, err := c.addProjectRoles(ctx, projectAgg, projectRoles...) if err != nil { return details, err } - - pushedEvents, err := c.eventstore.Push(ctx, events...) - if err != nil { - return nil, err - } - err = AppendAndReduce(roleWriteModel, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&roleWriteModel.WriteModel), nil + return c.pushAppendAndReduceDetails(ctx, roleWriteModel, events...) } -func (c *Commands) addProjectRoles(ctx context.Context, projectAgg *eventstore.Aggregate, projectRoles ...*domain.ProjectRole) ([]eventstore.Command, error) { +func (c *Commands) addProjectRoles(ctx context.Context, projectAgg *eventstore.Aggregate, projectRoles ...*AddProjectRole) ([]eventstore.Command, error) { var events []eventstore.Command for _, projectRole := range projectRoles { - projectRole.AggregateID = projectAgg.ID + if projectRole.ResourceOwner != projectAgg.ResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-4Q2WjlbHvc", "Errors.Project.Role.Invalid") + } if !projectRole.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.Role.Invalid") } @@ -81,42 +112,55 @@ func (c *Commands) addProjectRoles(ctx context.Context, projectAgg *eventstore.A return events, nil } -func (c *Commands) ChangeProjectRole(ctx context.Context, projectRole *domain.ProjectRole, resourceOwner string) (_ *domain.ProjectRole, err error) { +type ChangeProjectRole struct { + models.ObjectRoot + + Key string + DisplayName string + Group string +} + +func (p *ChangeProjectRole) IsValid() bool { + return p.AggregateID != "" && p.Key != "" +} + +func (c *Commands) ChangeProjectRole(ctx context.Context, projectRole *ChangeProjectRole) (_ *domain.ObjectDetails, err error) { if !projectRole.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2ilfW", "Errors.Project.Invalid") } - err = c.checkProjectExists(ctx, projectRole.AggregateID, resourceOwner) + projectResourceOwner, err := c.checkProjectExists(ctx, projectRole.AggregateID, projectRole.ResourceOwner) if err != nil { return nil, err } + if projectRole.ResourceOwner == "" { + projectRole.ResourceOwner = projectResourceOwner + } + if err := c.checkPermissionWriteProjectRole(ctx, projectRole.ResourceOwner, projectRole.Key); err != nil { + return nil, err + } - existingRole, err := c.getProjectRoleWriteModelByID(ctx, projectRole.Key, projectRole.AggregateID, resourceOwner) + existingRole, err := c.getProjectRoleWriteModelByID(ctx, projectRole.Key, projectRole.AggregateID, projectRole.ResourceOwner) if err != nil { return nil, err } - if existingRole.State == domain.ProjectRoleStateUnspecified || existingRole.State == domain.ProjectRoleStateRemoved { + if !existingRole.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-vv8M9", "Errors.Project.Role.NotExisting") } + if existingRole.ResourceOwner != projectResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-3MizLWveMf", "Errors.Project.Role.Invalid") + } - projectAgg := ProjectAggregateFromWriteModel(&existingRole.WriteModel) + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingRole.WriteModel) changeEvent, changed, err := existingRole.NewProjectRoleChangedEvent(ctx, projectAgg, projectRole.Key, projectRole.DisplayName, projectRole.Group) if err != nil { return nil, err } if !changed { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5M0cs", "Errors.NoChangesFound") + return writeModelToObjectDetails(&existingRole.WriteModel), nil } - pushedEvents, err := c.eventstore.Push(ctx, changeEvent) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingRole, pushedEvents...) - if err != nil { - return nil, err - } - return roleWriteModelToRole(existingRole), nil + return c.pushAppendAndReduceDetails(ctx, existingRole, changeEvent) } func (c *Commands) RemoveProjectRole(ctx context.Context, projectID, key, resourceOwner string, cascadingProjectGrantIds []string, cascadeUserGrantIDs ...string) (details *domain.ObjectDetails, err error) { @@ -127,10 +171,14 @@ func (c *Commands) RemoveProjectRole(ctx context.Context, projectID, key, resour if err != nil { return details, err } - if existingRole.State == domain.ProjectRoleStateUnspecified || existingRole.State == domain.ProjectRoleStateRemoved { - return details, zerrors.ThrowNotFound(nil, "COMMAND-m9vMf", "Errors.Project.Role.NotExisting") + // return if project role is not existing + if !existingRole.State.Exists() { + return writeModelToObjectDetails(&existingRole.WriteModel), nil } - projectAgg := ProjectAggregateFromWriteModel(&existingRole.WriteModel) + if err := c.checkPermissionDeleteProjectRole(ctx, existingRole.ResourceOwner, existingRole.Key); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingRole.WriteModel) events := []eventstore.Command{ project.NewRoleRemovedEvent(ctx, projectAgg, key), } @@ -153,15 +201,11 @@ func (c *Commands) RemoveProjectRole(ctx context.Context, projectID, key, resour events = append(events, event) } - pushedEvents, err := c.eventstore.Push(ctx, events...) - if err != nil { - return nil, err - } - err = AppendAndReduce(existingRole, pushedEvents...) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&existingRole.WriteModel), nil + return c.pushAppendAndReduceDetails(ctx, existingRole, events...) +} + +func (c *Commands) checkPermissionDeleteProjectRole(ctx context.Context, resourceOwner, roleKey string) error { + return c.checkPermission(ctx, domain.PermissionProjectRoleDelete, resourceOwner, roleKey) } func (c *Commands) getProjectRoleWriteModelByID(ctx context.Context, key, projectID, resourceOwner string) (*ProjectRoleWriteModel, error) { diff --git a/internal/command/project_role_model.go b/internal/command/project_role_model.go index 641879f238..3fc9a6c814 100644 --- a/internal/command/project_role_model.go +++ b/internal/command/project_role_model.go @@ -17,6 +17,10 @@ type ProjectRoleWriteModel struct { State domain.ProjectRoleState } +func (wm *ProjectRoleWriteModel) GetWriteModel() *eventstore.WriteModel { + return &wm.WriteModel +} + func NewProjectRoleWriteModelWithKey(key, projectID, resourceOwner string) *ProjectRoleWriteModel { return &ProjectRoleWriteModel{ WriteModel: eventstore.WriteModel{ diff --git a/internal/command/project_role_test.go b/internal/command/project_role_test.go index 2dc7ee35a6..14db198270 100644 --- a/internal/command/project_role_test.go +++ b/internal/command/project_role_test.go @@ -16,15 +16,15 @@ import ( func TestCommandSide_AddProjectRole(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - role *domain.ProjectRole - resourceOwner string + ctx context.Context + role *AddProjectRole } type res struct { - want *domain.ProjectRole + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -36,8 +36,7 @@ func TestCommandSide_AddProjectRole(t *testing.T) { { name: "project not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -55,16 +54,16 @@ func TestCommandSide_AddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &AddProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, Key: "key1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -73,8 +72,7 @@ func TestCommandSide_AddProjectRole(t *testing.T) { { name: "invalid role, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -85,15 +83,15 @@ func TestCommandSide_AddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &AddProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -102,8 +100,7 @@ func TestCommandSide_AddProjectRole(t *testing.T) { { name: "role key already exists, already exists error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -123,10 +120,11 @@ func TestCommandSide_AddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &AddProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, @@ -134,7 +132,6 @@ func TestCommandSide_AddProjectRole(t *testing.T) { DisplayName: "key", Group: "group", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -143,8 +140,7 @@ func TestCommandSide_AddProjectRole(t *testing.T) { { name: "add role,ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -164,10 +160,11 @@ func TestCommandSide_AddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &AddProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, @@ -175,10 +172,41 @@ func TestCommandSide_AddProjectRole(t *testing.T) { DisplayName: "key", Group: "group", }, - resourceOwner: "org1", }, res: res{ - want: &domain.ProjectRole{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "add role, resourceowner, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), + expectPush( + project.NewRoleAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "key1", + "key", + "group", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + role: &AddProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", ResourceOwner: "org1", @@ -188,14 +216,20 @@ func TestCommandSide_AddProjectRole(t *testing.T) { Group: "group", }, }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.AddProjectRole(tt.args.ctx, tt.args.role, tt.args.resourceOwner) + got, err := r.AddProjectRole(tt.args.ctx, tt.args.role) if tt.res.err == nil { assert.NoError(t, err) } @@ -203,7 +237,7 @@ func TestCommandSide_AddProjectRole(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -211,11 +245,12 @@ func TestCommandSide_AddProjectRole(t *testing.T) { func TestCommandSide_BulkAddProjectRole(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context - roles []*domain.ProjectRole + roles []*AddProjectRole projectID string resourceOwner string } @@ -232,8 +267,7 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { { name: "project not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -251,10 +285,11 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - roles: []*domain.ProjectRole{ + roles: []*AddProjectRole{ { ObjectRoot: models.ObjectRoot{ AggregateID: "project1", @@ -271,8 +306,7 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { { name: "invalid role, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -283,10 +317,11 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - roles: []*domain.ProjectRole{ + roles: []*AddProjectRole{ { ObjectRoot: models.ObjectRoot{}, }, @@ -304,8 +339,7 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { { name: "role key already exists, already exists error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -332,16 +366,23 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - roles: []*domain.ProjectRole{ + roles: []*AddProjectRole{ { + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, Key: "key1", DisplayName: "key", Group: "group", }, { + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, Key: "key2", DisplayName: "key2", Group: "group", @@ -357,8 +398,7 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { { name: "add roles,ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -385,16 +425,23 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - roles: []*domain.ProjectRole{ + roles: []*AddProjectRole{ { + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, Key: "key1", DisplayName: "key", Group: "group", }, { + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, Key: "key2", DisplayName: "key2", Group: "group", @@ -413,7 +460,8 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.BulkAddProjectRole(tt.args.ctx, tt.args.projectID, tt.args.resourceOwner, tt.args.roles) if tt.res.err == nil { @@ -431,15 +479,15 @@ func TestCommandSide_BulkAddProjectRole(t *testing.T) { func TestCommandSide_ChangeProjectRole(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - role *domain.ProjectRole - resourceOwner string + ctx context.Context + role *ChangeProjectRole } type res struct { - want *domain.ProjectRole + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -451,18 +499,16 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { { name: "invalid role, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &ChangeProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -471,8 +517,7 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { { name: "project not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -490,16 +535,16 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &ChangeProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, Key: "key1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -508,8 +553,7 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { { name: "role removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -536,10 +580,11 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &ChangeProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, @@ -547,7 +592,6 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { DisplayName: "key", Group: "group", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -556,8 +600,7 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { { name: "role not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -578,10 +621,11 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &ChangeProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, @@ -589,17 +633,17 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { DisplayName: "key", Group: "group", }, - resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "role changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -623,10 +667,11 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { newRoleChangedEvent(context.Background(), "project1", "org1", "key1", "keychanged", "groupchanged"), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - role: &domain.ProjectRole{ + role: &ChangeProjectRole{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, @@ -634,17 +679,10 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { DisplayName: "keychanged", Group: "groupchanged", }, - resourceOwner: "org1", }, res: res{ - want: &domain.ProjectRole{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - ResourceOwner: "org1", - }, - Key: "key1", - DisplayName: "keychanged", - Group: "groupchanged", + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, @@ -652,9 +690,10 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeProjectRole(tt.args.ctx, tt.args.role, tt.args.resourceOwner) + got, err := r.ChangeProjectRole(tt.args.ctx, tt.args.role) if tt.res.err == nil { assert.NoError(t, err) } @@ -662,7 +701,7 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -670,7 +709,8 @@ func TestCommandSide_ChangeProjectRole(t *testing.T) { func TestCommandSide_RemoveProjectRole(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -693,9 +733,8 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { { name: "invalid projectid, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -709,9 +748,8 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { { name: "invalid key, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -726,10 +764,10 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { { name: "role not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -738,14 +776,15 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { resourceOwner: "org1", }, res: res{ - err: zerrors.IsNotFound, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "role removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewRoleAddedEvent(context.Background(), @@ -763,6 +802,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -771,14 +811,15 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { resourceOwner: "org1", }, res: res{ - err: zerrors.IsNotFound, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "role removed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewRoleAddedEvent(context.Background(), @@ -796,6 +837,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -812,8 +854,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { { name: "role removed with cascadingProjectGrantids, grant not found, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewRoleAddedEvent(context.Background(), @@ -832,6 +873,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -849,8 +891,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { { name: "role removed with cascadingProjectGrantids, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewRoleAddedEvent(context.Background(), @@ -883,6 +924,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -900,8 +942,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { { name: "role removed with cascadingUserGrantIDs, grant not found, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewRoleAddedEvent(context.Background(), @@ -920,6 +961,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -937,8 +979,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { { name: "role removed with cascadingUserGrantIDs, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewRoleAddedEvent(context.Background(), @@ -969,6 +1010,7 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -987,7 +1029,8 @@ func TestCommandSide_RemoveProjectRole(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveProjectRole(tt.args.ctx, tt.args.projectID, tt.args.key, tt.args.resourceOwner, tt.args.cascadingProjectGrantIDs, tt.args.cascadingUserGrantIDs...) if tt.res.err == nil { diff --git a/internal/command/project_test.go b/internal/command/project_test.go index 842e1aa640..6c03420f6b 100644 --- a/internal/command/project_test.go +++ b/internal/command/project_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -18,16 +19,16 @@ import ( func TestCommandSide_AddProject(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator id.Generator + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - project *domain.Project - resourceOwner string + ctx context.Context + project *AddProject } type res struct { - want *domain.Project + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -39,14 +40,14 @@ func TestCommandSide_AddProject(t *testing.T) { { name: "invalid project, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "instanceID"), - project: &domain.Project{}, - resourceOwner: "org1", + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + project: &AddProject{ + ObjectRoot: models.ObjectRoot{ResourceOwner: "org1"}, + }, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -55,62 +56,51 @@ func TestCommandSide_AddProject(t *testing.T) { { name: "project, resourceowner empty", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "instanceID"), - project: &domain.Project{ + project: &AddProject{ + ObjectRoot: models.ObjectRoot{ResourceOwner: ""}, Name: "project", ProjectRoleAssertion: true, ProjectRoleCheck: true, HasProjectCheck: true, PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, - resourceOwner: "", }, res: res{ err: zerrors.IsErrorInvalidArgument, }, }, { - name: "project, error already exists", + name: "project, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), - expectPushFailed(zerrors.ThrowAlreadyExists(nil, "ERROR", "internl"), - project.NewProjectAddedEvent( - context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "project", true, true, true, - domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, - ), - ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"), + checkPermission: newMockPermissionCheckNotAllowed(), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "instanceID"), - project: &domain.Project{ + project: &AddProject{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"}, Name: "project", ProjectRoleAssertion: true, ProjectRoleCheck: true, HasProjectCheck: true, PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, - resourceOwner: "org1", }, res: res{ - err: zerrors.IsErrorAlreadyExists, + err: zerrors.IsPermissionDenied, }, }, { name: "project, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -120,18 +110,19 @@ func TestCommandSide_AddProject(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "instanceID"), - project: &domain.Project{ + project: &AddProject{ + ObjectRoot: models.ObjectRoot{ResourceOwner: "org1"}, Name: "project", ProjectRoleAssertion: true, ProjectRoleCheck: true, HasProjectCheck: true, PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -140,8 +131,7 @@ func TestCommandSide_AddProject(t *testing.T) { { name: "project, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), expectPush( project.NewProjectAddedEvent( @@ -152,25 +142,46 @@ func TestCommandSide_AddProject(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"), + checkPermission: newMockPermissionCheckAllowed(), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "project1"), }, args: args{ ctx: authz.WithInstanceID(context.Background(), "instanceID"), - project: &domain.Project{ + project: &AddProject{ + ObjectRoot: models.ObjectRoot{ResourceOwner: "org1"}, Name: "project", ProjectRoleAssertion: true, ProjectRoleCheck: true, HasProjectCheck: true, PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, - resourceOwner: "org1", }, res: res{ - want: &domain.Project{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "project1", - }, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "project, with id, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectPush( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + project: &AddProject{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"}, Name: "project", ProjectRoleAssertion: true, ProjectRoleCheck: true, @@ -178,16 +189,22 @@ func TestCommandSide_AddProject(t *testing.T) { PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, }, }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + checkPermission: tt.fields.checkPermission, } c.setMilestonesCompletedForTest("instanceID") - got, err := c.AddProject(tt.args.ctx, tt.args.project, tt.args.resourceOwner) + got, err := c.AddProject(tt.args.ctx, tt.args.project) if tt.res.err == nil { assert.NoError(t, err) } @@ -195,7 +212,8 @@ func TestCommandSide_AddProject(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assert.NotEmpty(t, got.ID) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -203,15 +221,16 @@ func TestCommandSide_AddProject(t *testing.T) { func TestCommandSide_ChangeProject(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context - project *domain.Project + project *ChangeProject resourceOwner string } type res struct { - want *domain.Project + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -223,16 +242,16 @@ func TestCommandSide_ChangeProject(t *testing.T) { { name: "invalid project, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - project: &domain.Project{ + project: &ChangeProject{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, + Name: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -243,14 +262,13 @@ func TestCommandSide_ChangeProject(t *testing.T) { { name: "invalid project empty aggregateid, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - project: &domain.Project{ - Name: "project", + project: &ChangeProject{ + Name: gu.Ptr("project"), }, resourceOwner: "org1", }, @@ -261,18 +279,18 @@ func TestCommandSide_ChangeProject(t *testing.T) { { name: "project not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - project: &domain.Project{ + project: &ChangeProject{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - Name: "project change", + Name: gu.Ptr("project change"), }, resourceOwner: "org1", }, @@ -283,8 +301,7 @@ func TestCommandSide_ChangeProject(t *testing.T) { { name: "project removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -300,14 +317,15 @@ func TestCommandSide_ChangeProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - project: &domain.Project{ + project: &ChangeProject{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - Name: "project change", + Name: gu.Ptr("project change"), }, resourceOwner: "org1", }, @@ -316,10 +334,9 @@ func TestCommandSide_ChangeProject(t *testing.T) { }, }, { - name: "no changes, precondition error", + name: "no changes, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -329,30 +346,65 @@ func TestCommandSide_ChangeProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - project: &domain.Project{ + project: &ChangeProject{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - Name: "project", - ProjectRoleAssertion: true, - ProjectRoleCheck: true, - HasProjectCheck: true, - PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + Name: gu.Ptr("project"), + ProjectRoleAssertion: gu.Ptr(true), + ProjectRoleCheck: gu.Ptr(true), + HasProjectCheck: gu.Ptr(true), + PrivateLabelingSetting: gu.Ptr(domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), }, resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "no changes, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + project: &ChangeProject{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + Name: gu.Ptr("project"), + ProjectRoleAssertion: gu.Ptr(true), + ProjectRoleCheck: gu.Ptr(true), + HasProjectCheck: gu.Ptr(true), + PrivateLabelingSetting: gu.Ptr(domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsPermissionDenied, }, }, { name: "project change with name and unique constraints, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -374,40 +426,32 @@ func TestCommandSide_ChangeProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - project: &domain.Project{ + project: &ChangeProject{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - Name: "project-new", - ProjectRoleAssertion: false, - ProjectRoleCheck: false, - HasProjectCheck: false, - PrivateLabelingSetting: domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy, + Name: gu.Ptr("project-new"), + ProjectRoleAssertion: gu.Ptr(false), + ProjectRoleCheck: gu.Ptr(false), + HasProjectCheck: gu.Ptr(false), + PrivateLabelingSetting: gu.Ptr(domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy), }, resourceOwner: "org1", }, res: res{ - want: &domain.Project{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - ResourceOwner: "org1", - }, - Name: "project-new", - ProjectRoleAssertion: false, - ProjectRoleCheck: false, - HasProjectCheck: false, - PrivateLabelingSetting: domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, { name: "project change without name and unique constraints, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -429,32 +473,25 @@ func TestCommandSide_ChangeProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), - project: &domain.Project{ + project: &ChangeProject{ ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - Name: "project", - ProjectRoleAssertion: false, - ProjectRoleCheck: false, - HasProjectCheck: false, - PrivateLabelingSetting: domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy, + Name: gu.Ptr("project"), + ProjectRoleAssertion: gu.Ptr(false), + ProjectRoleCheck: gu.Ptr(false), + HasProjectCheck: gu.Ptr(false), + PrivateLabelingSetting: gu.Ptr(domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy), }, resourceOwner: "org1", }, res: res{ - want: &domain.Project{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - ResourceOwner: "org1", - }, - Name: "project", - ProjectRoleAssertion: false, - ProjectRoleCheck: false, - HasProjectCheck: false, - PrivateLabelingSetting: domain.PrivateLabelingSettingEnforceProjectResourceOwnerPolicy, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, @@ -462,9 +499,10 @@ func TestCommandSide_ChangeProject(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeProject(tt.args.ctx, tt.args.project, tt.args.resourceOwner) + got, err := r.ChangeProject(tt.args.ctx, tt.args.project) if tt.res.err == nil { assert.NoError(t, err) } @@ -472,7 +510,7 @@ func TestCommandSide_ChangeProject(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -480,7 +518,8 @@ func TestCommandSide_ChangeProject(t *testing.T) { func TestCommandSide_DeactivateProject(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -500,9 +539,8 @@ func TestCommandSide_DeactivateProject(t *testing.T) { { name: "invalid project id, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -513,29 +551,13 @@ func TestCommandSide_DeactivateProject(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, - { - name: "invalid resourceowner, invalid error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - resourceOwner: "", - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, { name: "project not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -549,8 +571,7 @@ func TestCommandSide_DeactivateProject(t *testing.T) { { name: "project removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -566,6 +587,7 @@ func TestCommandSide_DeactivateProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -579,8 +601,7 @@ func TestCommandSide_DeactivateProject(t *testing.T) { { name: "project already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -594,6 +615,7 @@ func TestCommandSide_DeactivateProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -605,10 +627,9 @@ func TestCommandSide_DeactivateProject(t *testing.T) { }, }, { - name: "project deactivate, ok", + name: "project deactivate,no resourceOwner, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -622,6 +643,61 @@ func TestCommandSide_DeactivateProject(t *testing.T) { &project.NewAggregate("project1", "org1").Aggregate), ), ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "project deactivate, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "project deactivate, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + expectPush( + project.NewProjectDeactivatedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -638,7 +714,8 @@ func TestCommandSide_DeactivateProject(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.DeactivateProject(tt.args.ctx, tt.args.projectID, tt.args.resourceOwner) if tt.res.err == nil { @@ -656,7 +733,8 @@ func TestCommandSide_DeactivateProject(t *testing.T) { func TestCommandSide_ReactivateProject(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -676,9 +754,8 @@ func TestCommandSide_ReactivateProject(t *testing.T) { { name: "invalid project id, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -689,29 +766,13 @@ func TestCommandSide_ReactivateProject(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, - { - name: "invalid resourceowner, invalid error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - resourceOwner: "", - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, { name: "project not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -725,8 +786,7 @@ func TestCommandSide_ReactivateProject(t *testing.T) { { name: "project removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -742,6 +802,7 @@ func TestCommandSide_ReactivateProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -755,8 +816,7 @@ func TestCommandSide_ReactivateProject(t *testing.T) { { name: "project not inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -766,6 +826,7 @@ func TestCommandSide_ReactivateProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -777,10 +838,9 @@ func TestCommandSide_ReactivateProject(t *testing.T) { }, }, { - name: "project reactivate, ok", + name: "project reactivate, no resourceOwner, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -798,6 +858,69 @@ func TestCommandSide_ReactivateProject(t *testing.T) { &project.NewAggregate("project1", "org1").Aggregate), ), ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "project reactivate, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + eventFromEventPusher( + project.NewProjectDeactivatedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "project reactivate, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + eventFromEventPusher( + project.NewProjectDeactivatedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate), + ), + ), + expectPush( + project.NewProjectReactivatedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -814,7 +937,8 @@ func TestCommandSide_ReactivateProject(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.ReactivateProject(tt.args.ctx, tt.args.projectID, tt.args.resourceOwner) if tt.res.err == nil { @@ -832,7 +956,8 @@ func TestCommandSide_ReactivateProject(t *testing.T) { func TestCommandSide_RemoveProject(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -852,9 +977,8 @@ func TestCommandSide_RemoveProject(t *testing.T) { { name: "invalid project id, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -868,9 +992,8 @@ func TestCommandSide_RemoveProject(t *testing.T) { { name: "invalid resourceowner, invalid error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -884,10 +1007,10 @@ func TestCommandSide_RemoveProject(t *testing.T) { { name: "project not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -901,8 +1024,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { { name: "project removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -918,6 +1040,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -931,8 +1054,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { { name: "project remove, without entityConstraints, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -950,6 +1072,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { nil), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -965,8 +1088,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { { name: "project remove, with entityConstraints, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -1003,6 +1125,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1018,8 +1141,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { { name: "project remove, with multiple entityConstraints, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -1090,6 +1212,7 @@ func TestCommandSide_RemoveProject(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -1106,7 +1229,8 @@ func TestCommandSide_RemoveProject(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveProject(tt.args.ctx, tt.args.projectID, tt.args.resourceOwner) if tt.res.err == nil { @@ -1122,6 +1246,328 @@ func TestCommandSide_RemoveProject(t *testing.T) { } } +func TestCommandSide_DeleteProject(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + projectID string + resourceOwner string + } + type res struct { + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "invalid project id, invalid error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "", + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "project not existing, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: nil, + }, + }, + { + name: "project removed, not found error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + eventFromEventPusher( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + nil), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: nil, + }, + }, { + name: "project remove, no resourceOwner, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + // no saml application events + expectFilter(), + expectPush( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + nil), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "", + }, + res: res{ + err: nil, + }, + }, + { + name: "project remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "project remove, without entityConstraints, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + // no saml application events + expectFilter(), + expectPush( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + nil), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: nil, + }, + }, + { + name: "project remove, with entityConstraints, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + eventFromEventPusher( + project.NewSAMLConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "https://test.com/saml/metadata", + []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), + "http://localhost:8080/saml/metadata", + domain.LoginVersionUnspecified, + "", + ), + ), + ), + expectPush( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + []*eventstore.UniqueConstraint{ + project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test.com/saml/metadata"), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: nil, + }, + }, + { + name: "project remove, with multiple entityConstraints, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy), + ), + ), + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + eventFromEventPusher( + project.NewSAMLConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "https://test1.com/saml/metadata", + []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), + "", + domain.LoginVersionUnspecified, + "", + ), + ), + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app2", + "app", + )), + eventFromEventPusher( + project.NewSAMLConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app2", + "https://test2.com/saml/metadata", + []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), + "", + domain.LoginVersionUnspecified, + "", + ), + ), + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app3", + "app", + )), + eventFromEventPusher( + project.NewSAMLConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app3", + "https://test3.com/saml/metadata", + []byte("\n\n \n urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\n \n \n \n"), + "", + domain.LoginVersionUnspecified, + "", + ), + ), + ), + expectPush( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + []*eventstore.UniqueConstraint{ + project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test1.com/saml/metadata"), + project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test2.com/saml/metadata"), + project.NewRemoveSAMLConfigEntityIDUniqueConstraint("https://test3.com/saml/metadata"), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + _, err := r.DeleteProject(tt.args.ctx, tt.args.projectID, tt.args.resourceOwner) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + }) + } +} + func newProjectChangedEvent(ctx context.Context, projectID, resourceOwner, oldName, newName string, roleAssertion, roleCheck, hasProjectCheck bool, privateLabelingSetting domain.PrivateLabelingSetting) *project.ProjectChangeEvent { changes := []project.ProjectChanges{ project.ChangeProjectRoleAssertion(roleAssertion), @@ -1132,12 +1578,11 @@ func newProjectChangedEvent(ctx context.Context, projectID, resourceOwner, oldNa if newName != "" { changes = append(changes, project.ChangeName(newName)) } - event, _ := project.NewProjectChangeEvent(ctx, + return project.NewProjectChangeEvent(ctx, &project.NewAggregate(projectID, resourceOwner).Aggregate, oldName, changes, ) - return event } func TestAddProject(t *testing.T) { diff --git a/internal/domain/permission.go b/internal/domain/permission.go index 0ddf08a664..fd300f63b9 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -38,6 +38,15 @@ const ( PermissionOrgRead = "org.read" PermissionIDPRead = "iam.idp.read" PermissionOrgIDPRead = "org.idp.read" + PermissionProjectWrite = "project.write" + PermissionProjectRead = "project.read" + PermissionProjectDelete = "project.delete" + PermissionProjectGrantWrite = "project.grant.write" + PermissionProjectGrantRead = "project.grant.read" + PermissionProjectGrantDelete = "project.grant.delete" + PermissionProjectRoleWrite = "project.role.write" + PermissionProjectRoleRead = "project.role.read" + PermissionProjectRoleDelete = "project.role.delete" ) // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. diff --git a/internal/domain/project_grant.go b/internal/domain/project_grant.go index 31c6d9edbc..6376c9ed74 100644 --- a/internal/domain/project_grant.go +++ b/internal/domain/project_grant.go @@ -26,17 +26,12 @@ func (s ProjectGrantState) Valid() bool { return s > ProjectGrantStateUnspecified && s < projectGrantStateMax } -func (p *ProjectGrant) IsValid() bool { - return p.GrantedOrgID != "" +func (s ProjectGrantState) Exists() bool { + return s != ProjectGrantStateUnspecified && s != ProjectGrantStateRemoved } -func (g *ProjectGrant) HasInvalidRoles(validRoles []string) bool { - for _, roleKey := range g.RoleKeys { - if !containsRoleKey(roleKey, validRoles) { - return true - } - } - return false +func (p *ProjectGrant) IsValid() bool { + return p.GrantedOrgID != "" } func GetRemovedRoles(existingRoles, newRoles []string) []string { diff --git a/internal/domain/project_role.go b/internal/domain/project_role.go index e6c782bad1..c3593a1517 100644 --- a/internal/domain/project_role.go +++ b/internal/domain/project_role.go @@ -20,14 +20,23 @@ const ( ProjectRoleStateRemoved ) -func NewProjectRole(projectID, key string) *ProjectRole { - return &ProjectRole{ObjectRoot: models.ObjectRoot{AggregateID: projectID}, Key: key} +func (s ProjectRoleState) Exists() bool { + return s != ProjectRoleStateUnspecified && s != ProjectRoleStateRemoved } func (p *ProjectRole) IsValid() bool { return p.AggregateID != "" && p.Key != "" } +func HasInvalidRoles(validRoles, roles []string) bool { + for _, roleKey := range roles { + if !containsRoleKey(roleKey, validRoles) { + return true + } + } + return false +} + func containsRoleKey(roleKey string, validRoles []string) bool { for _, validRole := range validRoles { if roleKey == validRole { diff --git a/internal/integration/client.go b/internal/integration/client.go index 61645cc067..3efd682ee1 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -34,6 +34,7 @@ import ( oidc_pb_v2beta "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" "github.com/zitadel/zitadel/pkg/grpc/org/v2" org_v2beta "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + project_v2beta "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" user_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/user/v3alpha" userschema_v3alpha "github.com/zitadel/zitadel/pkg/grpc/resources/userschema/v3alpha" saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" @@ -71,6 +72,7 @@ type Client struct { UserV3Alpha user_v3alpha.ZITADELUsersClient SAMLv2 saml_pb.SAMLServiceClient SCIM *scim.Client + Projectv2Beta project_v2beta.ProjectServiceClient InstanceV2Beta instance.InstanceServiceClient } @@ -109,6 +111,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), SAMLv2: saml_pb.NewSAMLServiceClient(cc), SCIM: scim.NewScimClient(target), + Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), InstanceV2Beta: instance.NewInstanceServiceClient(cc), } return client, client.pollHealth(ctx) @@ -446,6 +449,70 @@ func (i *Instance) SetUserPassword(ctx context.Context, userID, password string, return resp.GetDetails() } +func (i *Instance) CreateProject(ctx context.Context, t *testing.T, orgID, name string, projectRoleCheck, hasProjectCheck bool) *project_v2beta.CreateProjectResponse { + if orgID == "" { + orgID = i.DefaultOrg.GetId() + } + + resp, err := i.Client.Projectv2Beta.CreateProject(ctx, &project_v2beta.CreateProjectRequest{ + OrganizationId: orgID, + Name: name, + AuthorizationRequired: projectRoleCheck, + ProjectAccessRequired: hasProjectCheck, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteProject(ctx context.Context, t *testing.T, projectID string) *project_v2beta.DeleteProjectResponse { + resp, err := i.Client.Projectv2Beta.DeleteProject(ctx, &project_v2beta.DeleteProjectRequest{ + Id: projectID, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeactivateProject(ctx context.Context, t *testing.T, projectID string) *project_v2beta.DeactivateProjectResponse { + resp, err := i.Client.Projectv2Beta.DeactivateProject(ctx, &project_v2beta.DeactivateProjectRequest{ + Id: projectID, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) ActivateProject(ctx context.Context, t *testing.T, projectID string) *project_v2beta.ActivateProjectResponse { + resp, err := i.Client.Projectv2Beta.ActivateProject(ctx, &project_v2beta.ActivateProjectRequest{ + Id: projectID, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) AddProjectRole(ctx context.Context, t *testing.T, projectID, roleKey, displayName, group string) *project_v2beta.AddProjectRoleResponse { + var groupP *string + if group != "" { + groupP = &group + } + + resp, err := i.Client.Projectv2Beta.AddProjectRole(ctx, &project_v2beta.AddProjectRoleRequest{ + ProjectId: projectID, + RoleKey: roleKey, + DisplayName: displayName, + Group: groupP, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) RemoveProjectRole(ctx context.Context, t *testing.T, projectID, roleKey string) *project_v2beta.RemoveProjectRoleResponse { + resp, err := i.Client.Projectv2Beta.RemoveProjectRole(ctx, &project_v2beta.RemoveProjectRoleRequest{ + ProjectId: projectID, + RoleKey: roleKey, + }) + require.NoError(t, err) + return resp +} + func (i *Instance) AddProviderToDefaultLoginPolicy(ctx context.Context, id string) { _, err := i.Client.Admin.AddIDPToLoginPolicy(ctx, &admin.AddIDPToLoginPolicyRequest{ IdpId: id, @@ -705,12 +772,40 @@ func (i *Instance) CreateIntentSession(t *testing.T, ctx context.Context, userID createResp.GetDetails().GetChangeDate().AsTime(), createResp.GetDetails().GetChangeDate().AsTime() } -func (i *Instance) CreateProjectGrant(ctx context.Context, projectID, grantedOrgID string) *mgmt.AddProjectGrantResponse { - resp, err := i.Client.Mgmt.AddProjectGrant(ctx, &mgmt.AddProjectGrantRequest{ - GrantedOrgId: grantedOrgID, - ProjectId: projectID, +func (i *Instance) CreateProjectGrant(ctx context.Context, t *testing.T, projectID, grantedOrgID string, roles ...string) *project_v2beta.CreateProjectGrantResponse { + resp, err := i.Client.Projectv2Beta.CreateProjectGrant(ctx, &project_v2beta.CreateProjectGrantRequest{ + GrantedOrganizationId: grantedOrgID, + ProjectId: projectID, + RoleKeys: roles, }) - logging.OnError(err).Panic("create project grant") + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteProjectGrant(ctx context.Context, t *testing.T, projectID, grantedOrgID string) *project_v2beta.DeleteProjectGrantResponse { + resp, err := i.Client.Projectv2Beta.DeleteProjectGrant(ctx, &project_v2beta.DeleteProjectGrantRequest{ + GrantedOrganizationId: grantedOrgID, + ProjectId: projectID, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeactivateProjectGrant(ctx context.Context, t *testing.T, projectID, grantedOrgID string) *project_v2beta.DeactivateProjectGrantResponse { + resp, err := i.Client.Projectv2Beta.DeactivateProjectGrant(ctx, &project_v2beta.DeactivateProjectGrantRequest{ + ProjectId: projectID, + GrantedOrganizationId: grantedOrgID, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) ActivateProjectGrant(ctx context.Context, t *testing.T, projectID, grantedOrgID string) *project_v2beta.ActivateProjectGrantResponse { + resp, err := i.Client.Projectv2Beta.ActivateProjectGrant(ctx, &project_v2beta.ActivateProjectGrantRequest{ + ProjectId: projectID, + GrantedOrganizationId: grantedOrgID, + }) + require.NoError(t, err) return resp } diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 159fcb0119..3323be3f97 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strings" + "testing" "time" "github.com/brianvoe/gofakeit/v6" @@ -133,13 +134,9 @@ func (i *Instance) CreateOIDCInactivateProjectClient(ctx context.Context, redire return client, err } -func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI string, loginVersion *app.LoginVersion) (*management.AddOIDCAppResponse, error) { - project, err := i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ - Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), - }) - if err != nil { - return nil, err - } +func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, t *testing.T, redirectURI string, loginVersion *app.LoginVersion) (*management.AddOIDCAppResponse, error) { + project := i.CreateProject(ctx, t, "", gofakeit.AppName(), false, false) + resp, err := i.Client.Mgmt.AddOIDCApp(ctx, &management.AddOIDCAppRequest{ ProjectId: project.GetId(), Name: fmt.Sprintf("app-%d", time.Now().UnixNano()), @@ -178,30 +175,11 @@ func (i *Instance) CreateOIDCImplicitFlowClient(ctx context.Context, redirectURI }) } -func (i *Instance) CreateOIDCTokenExchangeClient(ctx context.Context) (client *management.AddOIDCAppResponse, keyData []byte, err error) { - project, err := i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ - Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), - }) - if err != nil { - return nil, nil, err - } +func (i *Instance) CreateOIDCTokenExchangeClient(ctx context.Context, t *testing.T) (client *management.AddOIDCAppResponse, keyData []byte, err error) { + project := i.CreateProject(ctx, t, "", gofakeit.AppName(), false, false) return i.CreateOIDCWebClientJWT(ctx, "", "", project.GetId(), app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN) } -func (i *Instance) CreateProject(ctx context.Context) (*management.AddProjectResponse, error) { - return i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ - Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), - }) -} - -func (i *Instance) CreateProjectWithPermissionCheck(ctx context.Context, projectRoleCheck, hasProjectCheck bool) (*management.AddProjectResponse, error) { - return i.Client.Mgmt.AddProject(ctx, &management.AddProjectRequest{ - Name: fmt.Sprintf("project-%d", time.Now().UnixNano()), - HasProjectCheck: hasProjectCheck, - ProjectRoleCheck: projectRoleCheck, - }) -} - func (i *Instance) CreateAPIClientJWT(ctx context.Context, projectID string) (*management.AddAPIAppResponse, error) { return i.Client.Mgmt.AddAPIApp(ctx, &management.AddAPIAppRequest{ ProjectId: projectID, diff --git a/internal/query/project.go b/internal/query/project.go index 7501047182..ab58bd11a8 100644 --- a/internal/query/project.go +++ b/internal/query/project.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -72,11 +73,104 @@ var ( } ) +var ( + grantedProjectsAlias = table{ + name: "granted_projects", + instanceIDCol: projection.ProjectColumnInstanceID, + } + GrantedProjectColumnID = Column{ + name: projection.ProjectColumnID, + table: grantedProjectsAlias, + } + GrantedProjectColumnCreationDate = Column{ + name: projection.ProjectColumnCreationDate, + table: grantedProjectsAlias, + } + GrantedProjectColumnChangeDate = Column{ + name: projection.ProjectColumnChangeDate, + table: grantedProjectsAlias, + } + grantedProjectColumnResourceOwner = Column{ + name: projection.ProjectColumnResourceOwner, + table: grantedProjectsAlias, + } + grantedProjectColumnInstanceID = Column{ + name: projection.ProjectGrantColumnInstanceID, + table: grantedProjectsAlias, + } + grantedProjectColumnState = Column{ + name: "project_state", + table: grantedProjectsAlias, + } + GrantedProjectColumnName = Column{ + name: "project_name", + table: grantedProjectsAlias, + } + grantedProjectColumnProjectRoleAssertion = Column{ + name: projection.ProjectColumnProjectRoleAssertion, + table: grantedProjectsAlias, + } + grantedProjectColumnProjectRoleCheck = Column{ + name: projection.ProjectColumnProjectRoleCheck, + table: grantedProjectsAlias, + } + grantedProjectColumnHasProjectCheck = Column{ + name: projection.ProjectColumnHasProjectCheck, + table: grantedProjectsAlias, + } + grantedProjectColumnPrivateLabelingSetting = Column{ + name: projection.ProjectColumnPrivateLabelingSetting, + table: grantedProjectsAlias, + } + grantedProjectColumnGrantResourceOwner = Column{ + name: "project_grant_resource_owner", + table: grantedProjectsAlias, + } + grantedProjectColumnGrantedOrganization = Column{ + name: projection.ProjectGrantColumnGrantedOrgID, + table: grantedProjectsAlias, + } + grantedProjectColumnGrantedOrganizationName = Column{ + name: "granted_org_name", + table: grantedProjectsAlias, + } + grantedProjectColumnGrantState = Column{ + name: "project_grant_state", + table: grantedProjectsAlias, + } +) + type Projects struct { SearchResponse Projects []*Project } +func projectsCheckPermission(ctx context.Context, projects *Projects, permissionCheck domain.PermissionCheck) { + projects.Projects = slices.DeleteFunc(projects.Projects, + func(project *Project) bool { + return projectCheckPermission(ctx, project.ResourceOwner, project.ID, permissionCheck) != nil + }, + ) +} + +func projectCheckPermission(ctx context.Context, resourceOwner string, projectID string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionProjectRead, resourceOwner, projectID) +} + +func projectPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *ProjectAndGrantedProjectSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + grantedProjectColumnResourceOwner, + domain.PermissionProjectRead, + SingleOrgPermissionOption(queries.Queries), + OwnedRowsPermissionOption(GrantedProjectColumnID), + ) + return query.JoinClause(join, args...) +} + type Project struct { ID string CreationDate time.Time @@ -94,7 +188,19 @@ type Project struct { type ProjectSearchQueries struct { SearchRequest - Queries []SearchQuery + Queries []SearchQuery + GrantQueries []SearchQuery +} + +func (q *Queries) GetProjectByIDWithPermission(ctx context.Context, shouldTriggerBulk bool, id string, permissionCheck domain.PermissionCheck) (*Project, error) { + project, err := q.ProjectByID(ctx, shouldTriggerBulk, id) + if err != nil { + return nil, err + } + if err := projectCheckPermission(ctx, project.ResourceOwner, project.ID, permissionCheck); err != nil { + return nil, err + } + return project, nil } func (q *Queries) ProjectByID(ctx context.Context, shouldTriggerBulk bool, id string) (project *Project, err error) { @@ -125,7 +231,18 @@ func (q *Queries) ProjectByID(ctx context.Context, shouldTriggerBulk bool, id st return project, err } -func (q *Queries) SearchProjects(ctx context.Context, queries *ProjectSearchQueries) (projects *Projects, err error) { +func (q *Queries) SearchProjects(ctx context.Context, queries *ProjectSearchQueries, permissionCheck domain.PermissionCheck) (*Projects, error) { + projects, err := q.searchProjects(ctx, queries) + if err != nil { + return nil, err + } + if permissionCheck != nil { + projectsCheckPermission(ctx, projects, permissionCheck) + } + return projects, nil +} + +func (q *Queries) searchProjects(ctx context.Context, queries *ProjectSearchQueries) (projects *Projects, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -147,6 +264,89 @@ func (q *Queries) SearchProjects(ctx context.Context, queries *ProjectSearchQuer return projects, err } +type ProjectAndGrantedProjectSearchQueries struct { + SearchRequest + Queries []SearchQuery +} + +func (q *ProjectAndGrantedProjectSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { + query = q.SearchRequest.toQuery(query) + for _, q := range q.Queries { + query = q.toQuery(query) + } + return query +} + +func (q *Queries) SearchGrantedProjects(ctx context.Context, queries *ProjectAndGrantedProjectSearchQueries, permissionCheck domain.PermissionCheck) (*GrantedProjects, error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + projects, err := q.searchGrantedProjects(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + grantedProjectsCheckPermission(ctx, projects, permissionCheck) + } + return projects, nil +} + +func (q *Queries) searchGrantedProjects(ctx context.Context, queries *ProjectAndGrantedProjectSearchQueries, permissionCheckV2 bool) (grantedProjects *GrantedProjects, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, scan := prepareGrantedProjectsQuery() + query = projectPermissionCheckV2(ctx, query, permissionCheckV2, queries) + eq := sq.Eq{grantedProjectColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} + stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "QUERY-T84X9", "Errors.Query.InvalidRequest") + } + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + grantedProjects, err = scan(rows) + return err + }, stmt, args...) + if err != nil { + return nil, err + } + return grantedProjects, nil +} + +func NewGrantedProjectNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { + return NewTextQuery(GrantedProjectColumnName, value, method) +} + +func NewGrantedProjectResourceOwnerSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(grantedProjectColumnResourceOwner, value, TextEquals) +} + +func NewGrantedProjectIDSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(GrantedProjectColumnID, list, ListIn) +} + +func NewGrantedProjectOrganizationIDSearchQuery(value string) (SearchQuery, error) { + project, err := NewTextQuery(grantedProjectColumnResourceOwner, value, TextEquals) + if err != nil { + return nil, err + } + grant, err := NewTextQuery(grantedProjectColumnGrantedOrganization, value, TextEquals) + if err != nil { + return nil, err + } + return NewOrQuery(project, grant) +} + +func NewGrantedProjectGrantResourceOwnerSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(grantedProjectColumnGrantResourceOwner, value, TextEquals) +} + +func NewGrantedProjectGrantedOrganizationIDSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(grantedProjectColumnGrantedOrganization, value, TextEquals) +} + func NewProjectNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { return NewTextQuery(ProjectColumnName, value, method) } @@ -285,3 +485,171 @@ func prepareProjectsQuery() (sq.SelectBuilder, func(*sql.Rows) (*Projects, error }, nil } } + +type GrantedProjects struct { + SearchResponse + GrantedProjects []*GrantedProject +} + +func grantedProjectsCheckPermission(ctx context.Context, grantedProjects *GrantedProjects, permissionCheck domain.PermissionCheck) { + grantedProjects.GrantedProjects = slices.DeleteFunc(grantedProjects.GrantedProjects, + func(grantedProject *GrantedProject) bool { + return projectCheckPermission(ctx, grantedProject.ResourceOwner, grantedProject.ProjectID, permissionCheck) != nil + }, + ) +} + +type GrantedProject struct { + ProjectID string + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + InstanceID string + ProjectState domain.ProjectState + ProjectName string + + ProjectRoleAssertion bool + ProjectRoleCheck bool + HasProjectCheck bool + PrivateLabelingSetting domain.PrivateLabelingSetting + + GrantedOrgID string + OrgName string + ProjectGrantState domain.ProjectGrantState +} + +func prepareGrantedProjectsQuery() (sq.SelectBuilder, func(*sql.Rows) (*GrantedProjects, error)) { + return sq.Select( + GrantedProjectColumnID.identifier(), + GrantedProjectColumnCreationDate.identifier(), + GrantedProjectColumnChangeDate.identifier(), + grantedProjectColumnResourceOwner.identifier(), + grantedProjectColumnInstanceID.identifier(), + grantedProjectColumnState.identifier(), + GrantedProjectColumnName.identifier(), + grantedProjectColumnProjectRoleAssertion.identifier(), + grantedProjectColumnProjectRoleCheck.identifier(), + grantedProjectColumnHasProjectCheck.identifier(), + grantedProjectColumnPrivateLabelingSetting.identifier(), + grantedProjectColumnGrantedOrganization.identifier(), + grantedProjectColumnGrantedOrganizationName.identifier(), + grantedProjectColumnGrantState.identifier(), + countColumn.identifier(), + ).From(getProjectsAndGrantedProjectsFromQuery()). + PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) (*GrantedProjects, error) { + projects := make([]*GrantedProject, 0) + var ( + count uint64 + orgID = sql.NullString{} + orgName = sql.NullString{} + projectGrantState = sql.NullInt16{} + ) + for rows.Next() { + grantedProject := new(GrantedProject) + err := rows.Scan( + &grantedProject.ProjectID, + &grantedProject.CreationDate, + &grantedProject.ChangeDate, + &grantedProject.ResourceOwner, + &grantedProject.InstanceID, + &grantedProject.ProjectState, + &grantedProject.ProjectName, + &grantedProject.ProjectRoleAssertion, + &grantedProject.ProjectRoleCheck, + &grantedProject.HasProjectCheck, + &grantedProject.PrivateLabelingSetting, + &orgID, + &orgName, + &projectGrantState, + &count, + ) + if err != nil { + return nil, err + } + if orgID.Valid { + grantedProject.GrantedOrgID = orgID.String + } + if orgName.Valid { + grantedProject.OrgName = orgName.String + } + if projectGrantState.Valid { + grantedProject.ProjectGrantState = domain.ProjectGrantState(projectGrantState.Int16) + } + projects = append(projects, grantedProject) + } + + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-K9gEE", "Errors.Query.CloseRows") + } + + return &GrantedProjects{ + GrantedProjects: projects, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } +} + +func getProjectsAndGrantedProjectsFromQuery() string { + return "(" + + prepareProjects() + + " UNION ALL " + + prepareGrantedProjects() + + ") AS " + grantedProjectsAlias.identifier() +} + +func prepareProjects() string { + builder := sq.Select( + ProjectColumnID.identifier()+" AS "+GrantedProjectColumnID.name, + ProjectColumnCreationDate.identifier()+" AS "+GrantedProjectColumnCreationDate.name, + ProjectColumnChangeDate.identifier()+" AS "+GrantedProjectColumnChangeDate.name, + ProjectColumnResourceOwner.identifier()+" AS "+grantedProjectColumnResourceOwner.name, + ProjectColumnInstanceID.identifier()+" AS "+grantedProjectColumnInstanceID.name, + ProjectColumnState.identifier()+" AS "+grantedProjectColumnState.name, + ProjectColumnName.identifier()+" AS "+GrantedProjectColumnName.name, + ProjectColumnProjectRoleAssertion.identifier()+" AS "+grantedProjectColumnProjectRoleAssertion.name, + ProjectColumnProjectRoleCheck.identifier()+" AS "+grantedProjectColumnProjectRoleCheck.name, + ProjectColumnHasProjectCheck.identifier()+" AS "+grantedProjectColumnHasProjectCheck.name, + ProjectColumnPrivateLabelingSetting.identifier()+" AS "+grantedProjectColumnPrivateLabelingSetting.name, + "NULL::TEXT AS "+grantedProjectColumnGrantResourceOwner.name, + "NULL::TEXT AS "+grantedProjectColumnGrantedOrganization.name, + "NULL::TEXT AS "+grantedProjectColumnGrantedOrganizationName.name, + "NULL::SMALLINT AS "+grantedProjectColumnGrantState.name, + countColumn.identifier()). + From(projectsTable.identifier()). + PlaceholderFormat(sq.Dollar) + + stmt, _ := builder.MustSql() + return stmt +} + +func prepareGrantedProjects() string { + grantedOrgTable := orgsTable.setAlias(ProjectGrantGrantedOrgTableAlias) + grantedOrgIDColumn := OrgColumnID.setTable(grantedOrgTable) + builder := sq.Select( + ProjectGrantColumnProjectID.identifier()+" AS "+GrantedProjectColumnID.name, + ProjectGrantColumnCreationDate.identifier()+" AS "+GrantedProjectColumnCreationDate.name, + ProjectGrantColumnChangeDate.identifier()+" AS "+GrantedProjectColumnChangeDate.name, + ProjectColumnResourceOwner.identifier()+" AS "+grantedProjectColumnResourceOwner.name, + ProjectGrantColumnInstanceID.identifier()+" AS "+grantedProjectColumnInstanceID.name, + ProjectColumnState.identifier()+" AS "+grantedProjectColumnState.name, + ProjectColumnName.identifier()+" AS "+GrantedProjectColumnName.name, + ProjectColumnProjectRoleAssertion.identifier()+" AS "+grantedProjectColumnProjectRoleAssertion.name, + ProjectColumnProjectRoleCheck.identifier()+" AS "+grantedProjectColumnProjectRoleCheck.name, + ProjectColumnHasProjectCheck.identifier()+" AS "+grantedProjectColumnHasProjectCheck.name, + ProjectColumnPrivateLabelingSetting.identifier()+" AS "+grantedProjectColumnPrivateLabelingSetting.name, + ProjectGrantColumnResourceOwner.identifier()+" AS "+grantedProjectColumnGrantResourceOwner.name, + ProjectGrantColumnGrantedOrgID.identifier()+" AS "+grantedProjectColumnGrantedOrganization.name, + ProjectGrantColumnGrantedOrgName.identifier()+" AS "+grantedProjectColumnGrantedOrganizationName.name, + ProjectGrantColumnState.identifier()+" AS "+grantedProjectColumnGrantState.name, + countColumn.identifier()). + From(projectGrantsTable.identifier()). + PlaceholderFormat(sq.Dollar). + LeftJoin(join(ProjectColumnID, ProjectGrantColumnProjectID)). + LeftJoin(join(grantedOrgIDColumn, ProjectGrantColumnGrantedOrgID)) + + stmt, _ := builder.MustSql() + return stmt +} diff --git a/internal/query/project_grant.go b/internal/query/project_grant.go index b971593c77..a0dbd7c121 100644 --- a/internal/query/project_grant.go +++ b/internal/query/project_grant.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -104,6 +105,43 @@ type ProjectGrantSearchQueries struct { Queries []SearchQuery } +func projectGrantsCheckPermission(ctx context.Context, projectGrants *ProjectGrants, permissionCheck domain.PermissionCheck) { + projectGrants.ProjectGrants = slices.DeleteFunc(projectGrants.ProjectGrants, + func(projectGrant *ProjectGrant) bool { + return projectGrantCheckPermission(ctx, projectGrant.ResourceOwner, projectGrant.GrantID, permissionCheck) != nil + }, + ) +} + +func projectGrantCheckPermission(ctx context.Context, resourceOwner string, grantID string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionProjectGrantRead, resourceOwner, grantID) +} + +func projectGrantPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *ProjectGrantSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + ProjectGrantColumnResourceOwner, + domain.PermissionProjectGrantRead, + SingleOrgPermissionOption(queries.Queries), + OwnedRowsPermissionOption(ProjectGrantColumnGrantID), + ) + return query.JoinClause(join, args...) +} + +func (q *Queries) GetProjectGrantByIDWithPermission(ctx context.Context, shouldTriggerBulk bool, id string, permissionCheck domain.PermissionCheck) (*ProjectGrant, error) { + projectGrant, err := q.ProjectGrantByID(ctx, shouldTriggerBulk, id) + if err != nil { + return nil, err + } + if err := projectCheckPermission(ctx, projectGrant.ResourceOwner, projectGrant.GrantID, permissionCheck); err != nil { + return nil, err + } + return projectGrant, nil +} + func (q *Queries) ProjectGrantByID(ctx context.Context, shouldTriggerBulk bool, id string) (grant *ProjectGrant, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -154,11 +192,24 @@ func (q *Queries) ProjectGrantByIDAndGrantedOrg(ctx context.Context, id, granted return grant, err } -func (q *Queries) SearchProjectGrants(ctx context.Context, queries *ProjectGrantSearchQueries) (grants *ProjectGrants, err error) { +func (q *Queries) SearchProjectGrants(ctx context.Context, queries *ProjectGrantSearchQueries, permissionCheck domain.PermissionCheck) (grants *ProjectGrants, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + projectsGrants, err := q.searchProjectGrants(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + projectGrantsCheckPermission(ctx, projectsGrants, permissionCheck) + } + return projectsGrants, nil +} + +func (q *Queries) searchProjectGrants(ctx context.Context, queries *ProjectGrantSearchQueries, permissionCheckV2 bool) (grants *ProjectGrants, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareProjectGrantsQuery() + query = projectGrantPermissionCheckV2(ctx, query, permissionCheckV2, queries) eq := sq.Eq{ ProjectGrantColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), } @@ -179,6 +230,7 @@ func (q *Queries) SearchProjectGrants(ctx context.Context, queries *ProjectGrant return grants, err } +// SearchProjectGrantsByProjectIDAndRoleKey is used internally to remove the roles of a project grant, so no permission check necessary func (q *Queries) SearchProjectGrantsByProjectIDAndRoleKey(ctx context.Context, projectID, roleKey string) (projects *ProjectGrants, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -195,13 +247,33 @@ func (q *Queries) SearchProjectGrantsByProjectIDAndRoleKey(ctx context.Context, if err != nil { return nil, err } - return q.SearchProjectGrants(ctx, searchQuery) + return q.SearchProjectGrants(ctx, searchQuery, nil) } func NewProjectGrantProjectIDSearchQuery(value string) (SearchQuery, error) { return NewTextQuery(ProjectGrantColumnProjectID, value, TextEquals) } +func (q *ProjectGrantSearchQueries) AppendPermissionQueries(permissions []string) error { + if !authz.HasGlobalPermission(permissions) { + ids := authz.GetAllPermissionCtxIDs(permissions) + query, err := NewProjectGrantIDsSearchQuery(ids) + if err != nil { + return err + } + q.Queries = append(q.Queries, query) + } + return nil +} + +func NewProjectGrantProjectIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(ProjectGrantColumnProjectID, list, ListIn) +} + func NewProjectGrantIDsSearchQuery(values []string) (SearchQuery, error) { list := make([]interface{}, len(values)) for i, value := range values { @@ -243,18 +315,6 @@ func (q *ProjectGrantSearchQueries) AppendGrantedOrgQuery(orgID string) error { return nil } -func (q *ProjectGrantSearchQueries) AppendPermissionQueries(permissions []string) error { - if !authz.HasGlobalPermission(permissions) { - ids := authz.GetAllPermissionCtxIDs(permissions) - query, err := NewProjectGrantIDsSearchQuery(ids) - if err != nil { - return err - } - q.Queries = append(q.Queries, query) - } - return nil -} - func (q *ProjectGrantSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { query = q.SearchRequest.toQuery(query) for _, q := range q.Queries { diff --git a/internal/query/project_role.go b/internal/query/project_role.go index ab4f40ca38..15ae806cd4 100644 --- a/internal/query/project_role.go +++ b/internal/query/project_role.go @@ -3,12 +3,14 @@ package query import ( "context" "database/sql" + "slices" "time" sq "github.com/Masterminds/squirrel" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -80,7 +82,45 @@ type ProjectRoleSearchQueries struct { Queries []SearchQuery } -func (q *Queries) SearchProjectRoles(ctx context.Context, shouldTriggerBulk bool, queries *ProjectRoleSearchQueries) (roles *ProjectRoles, err error) { +func projectRolesCheckPermission(ctx context.Context, projectRoles *ProjectRoles, permissionCheck domain.PermissionCheck) { + projectRoles.ProjectRoles = slices.DeleteFunc(projectRoles.ProjectRoles, + func(projectRole *ProjectRole) bool { + return projectRoleCheckPermission(ctx, projectRole.ResourceOwner, projectRole.Key, permissionCheck) != nil + }, + ) +} + +func projectRoleCheckPermission(ctx context.Context, resourceOwner string, grantID string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionProjectGrantRead, resourceOwner, grantID) +} + +func projectRolePermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *ProjectRoleSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + ProjectRoleColumnResourceOwner, + domain.PermissionProjectRoleRead, + SingleOrgPermissionOption(queries.Queries), + OwnedRowsPermissionOption(ProjectRoleColumnKey), + ) + return query.JoinClause(join, args...) +} + +func (q *Queries) SearchProjectRoles(ctx context.Context, shouldTriggerBulk bool, queries *ProjectRoleSearchQueries, permissionCheck domain.PermissionCheck) (roles *ProjectRoles, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + projectRoles, err := q.searchProjectRoles(ctx, shouldTriggerBulk, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + projectRolesCheckPermission(ctx, projectRoles, permissionCheck) + } + return projectRoles, nil +} + +func (q *Queries) searchProjectRoles(ctx context.Context, shouldTriggerBulk bool, queries *ProjectRoleSearchQueries, permissionCheckV2 bool) (roles *ProjectRoles, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -94,6 +134,7 @@ func (q *Queries) SearchProjectRoles(ctx context.Context, shouldTriggerBulk bool eq := sq.Eq{ProjectRoleColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} query, scan := prepareProjectRolesQuery() + query = projectRolePermissionCheckV2(ctx, query, permissionCheckV2, queries) stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-3N9ff", "Errors.Query.InvalidRequest") diff --git a/internal/repository/project/aggregate.go b/internal/repository/project/aggregate.go index 5391718812..15cb24278d 100644 --- a/internal/repository/project/aggregate.go +++ b/internal/repository/project/aggregate.go @@ -1,6 +1,8 @@ package project import ( + "context" + "github.com/zitadel/zitadel/internal/eventstore" ) @@ -23,3 +25,7 @@ func NewAggregate(id, resourceOwner string) *Aggregate { }, } } + +func AggregateFromWriteModel(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, AggregateType, AggregateVersion) +} diff --git a/internal/repository/project/project.go b/internal/repository/project/project.go index 44f882b3e1..c86fed5272 100644 --- a/internal/repository/project/project.go +++ b/internal/repository/project/project.go @@ -184,10 +184,7 @@ func NewProjectChangeEvent( aggregate *eventstore.Aggregate, oldName string, changes []ProjectChanges, -) (*ProjectChangeEvent, error) { - if len(changes) == 0 { - return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-mV9xc", "Errors.NoChangesFound") - } +) *ProjectChangeEvent { changeEvent := &ProjectChangeEvent{ BaseEvent: *eventstore.NewBaseEventForPush( ctx, @@ -199,7 +196,7 @@ func NewProjectChangeEvent( for _, change := range changes { change(changeEvent) } - return changeEvent, nil + return changeEvent } type ProjectChanges func(event *ProjectChangeEvent) diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 84c7823009..c90c44667c 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -2678,6 +2678,11 @@ service ManagementService { }; } + // Get Project By ID + // + // Deprecated: [Get Project](apis/resources/project_service_v2/project-service-get-project.api.mdx) to get project by ID. + // + // Returns a project owned by the organization (no granted projects). A Project is a vessel for different applications sharing the same role context. rpc GetProjectByID(GetProjectByIDRequest) returns (GetProjectByIDResponse) { option (google.api.http) = { get: "/projects/{id}" @@ -2690,8 +2695,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Get Project By ID"; - description: "Returns a project owned by the organization (no granted projects). A Project is a vessel for different applications sharing the same role context." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2703,6 +2707,11 @@ service ManagementService { }; } + // Get Granted Project By ID + // + // Deprecated: [List Projects](apis/resources/project_service_v2/project-service-list-projects.api.mdx) to get granted projects. + // + // Returns a project owned by another organization and granted to my organization. A Project is a vessel for different applications sharing the same role context. rpc GetGrantedProjectByID(GetGrantedProjectByIDRequest) returns (GetGrantedProjectByIDResponse) { option (google.api.http) = { get: "/granted_projects/{project_id}/grants/{grant_id}" @@ -2715,8 +2724,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Get Granted Project By ID"; - description: "Returns a project owned by another organization and granted to my organization. A Project is a vessel for different applications sharing the same role context." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2728,6 +2736,11 @@ service ManagementService { }; } + // List Projects + // + // Deprecated: [List Projects](apis/resources/project_service_v2/project-service-list-projects.api.mdx) to list all projects and granted projects. + // + // Lists projects my organization is the owner of (no granted projects). A Project is a vessel for different applications sharing the same role context. rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse) { option (google.api.http) = { post: "/projects/_search" @@ -2740,8 +2753,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Search Project"; - description: "Lists projects my organization is the owner of (no granted projects). A Project is a vessel for different applications sharing the same role context." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2753,6 +2765,11 @@ service ManagementService { }; } + // List Granted Projects + // + // Deprecated: [List Projects](apis/resources/project_service_v2/project-service-list-projects.api.mdx) to list all projects and granted projects. + // + // Lists projects my organization got granted from another organization. A Project is a vessel for different applications sharing the same role context. rpc ListGrantedProjects(ListGrantedProjectsRequest) returns (ListGrantedProjectsResponse) { option (google.api.http) = { post: "/granted_projects/_search" @@ -2765,8 +2782,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Search Granted Project"; - description: "Lists projects my organization got granted from another organization. A Project is a vessel for different applications sharing the same role context." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2828,6 +2844,11 @@ service ManagementService { }; } + // Create Project + // + // Deprecated: [Create Project](apis/resources/project_service_v2/project-service-create-project.api.mdx) to create a project. + // + // Create a new project. A Project is a vessel for different applications sharing the same role context. rpc AddProject(AddProjectRequest) returns (AddProjectResponse) { option (google.api.http) = { post: "/projects" @@ -2840,8 +2861,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Create Project"; - description: "Create a new project. A Project is a vessel for different applications sharing the same role context." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2853,6 +2873,11 @@ service ManagementService { }; } + // Update Project + // + // Deprecated: [Update Project](apis/resources/project_service_v2/project-service-update-project.api.mdx) to update a project. + // + // Update a project and its settings. A Project is a vessel for different applications sharing the same role context. rpc UpdateProject(UpdateProjectRequest) returns (UpdateProjectResponse) { option (google.api.http) = { put: "/projects/{id}" @@ -2866,8 +2891,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Update Project"; - description: "Update a project and its settings. A Project is a vessel for different applications sharing the same role context." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2879,6 +2903,11 @@ service ManagementService { }; } + // Deactivate Project + // + // Deprecated: [Deactivate Project](apis/resources/project_service_v2/project-service-deactivate-project.api.mdx) to deactivate a project. + // + // Set the state of a project to deactivated. Request returns an error if the project is already deactivated. rpc DeactivateProject(DeactivateProjectRequest) returns (DeactivateProjectResponse) { option (google.api.http) = { post: "/projects/{id}/_deactivate" @@ -2892,8 +2921,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Deactivate Project"; - description: "Set the state of a project to deactivated. Request returns an error if the project is already deactivated." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2905,6 +2933,11 @@ service ManagementService { }; } + // Activate Project + // + // Deprecated: [Activate Project](apis/resources/project_service_v2/project-service-activate-project.api.mdx) to activate a project. + // + // Set the state of a project to active. Request returns an error if the project is not deactivated. rpc ReactivateProject(ReactivateProjectRequest) returns (ReactivateProjectResponse) { option (google.api.http) = { post: "/projects/{id}/_reactivate" @@ -2918,8 +2951,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Reactivate Project"; - description: "Set the state of a project to active. Request returns an error if the project is not deactivated." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2931,6 +2963,11 @@ service ManagementService { }; } + // Remove Project + // + // Deprecated: [Delete Project](apis/resources/project_service_v2/project-service-delete-project.api.mdx) to remove a project. + // + // Project and all its sub-resources like project grants, applications, roles and user grants will be removed. rpc RemoveProject(RemoveProjectRequest) returns (RemoveProjectResponse) { option (google.api.http) = { delete: "/projects/{id}" @@ -2943,8 +2980,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Remove Project"; - description: "Project and all its sub-resources like project grants, applications, roles and user grants will be removed." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2956,6 +2992,11 @@ service ManagementService { }; } + // Search Project Roles + // + // Deprecated: [List Project Roles](apis/resources/project_service_v2/project-service-list-project-roles.api.mdx) to get project roles. + // + // Returns all roles of a project matching the search query. rpc ListProjectRoles(ListProjectRolesRequest) returns (ListProjectRolesResponse) { option (google.api.http) = { post: "/projects/{project_id}/roles/_search" @@ -2969,8 +3010,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - summary: "Search Project Roles"; - description: "Returns all roles of a project matching the search query." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -2982,6 +3022,11 @@ service ManagementService { }; } + // Add Project Role + // + // Deprecated: [Add Project Role](apis/resources/project_service_v2/project-service-add-project-role.api.mdx) to add a project role. + // + // Add a new project role to a project. The key must be unique within the project.\n\nDeprecated: please use user service v2 AddProjectRole. rpc AddProjectRole(AddProjectRoleRequest) returns (AddProjectRoleResponse) { option (google.api.http) = { post: "/projects/{project_id}/roles" @@ -2995,8 +3040,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - summary: "Add Project Role"; - description: "Add a new project role to a project. The key must be unique within the project." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3008,6 +3052,11 @@ service ManagementService { }; } + // Bulk add Project Role + // + // Deprecated: [Add Project Role](apis/resources/project_service_v2/project-service-add-project-role.api.mdx) to add a project role. + // + // Add a list of roles to a project. The keys must be unique within the project. rpc BulkAddProjectRoles(BulkAddProjectRolesRequest) returns (BulkAddProjectRolesResponse) { option (google.api.http) = { post: "/projects/{project_id}/roles/_bulk" @@ -3021,8 +3070,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - summary: "Bulk Add Project Role"; - description: "Add a list of roles to a project. The keys must be unique within the project." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3034,6 +3082,11 @@ service ManagementService { }; } + // Update Project Role + // + // Deprecated: [Update Project Role](apis/resources/project_service_v2/project-service-update-project-role.api.mdx) to update a project role. + // + // Change a project role. The key is not editable. If a key should change, remove the role and create a new one. rpc UpdateProjectRole(UpdateProjectRoleRequest) returns (UpdateProjectRoleResponse) { option (google.api.http) = { put: "/projects/{project_id}/roles/{role_key}" @@ -3047,8 +3100,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - summary: "Change Project Role"; - description: "Change a project role. The key is not editable. If a key should change, remove the role and create a new one." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3060,6 +3112,11 @@ service ManagementService { }; } + // Remove Project Role + // + // Deprecated: [Delete Project Role](apis/resources/project_service_v2/project-service-update-project-role.api.mdx) to remove a project role. + // + // Removes the role from the project and on every resource it has a dependency. This includes project grants and user grants. rpc RemoveProjectRole(RemoveProjectRoleRequest) returns (RemoveProjectRoleResponse) { option (google.api.http) = { delete: "/projects/{project_id}/roles/{role_key}" @@ -3072,8 +3129,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - summary: "Remove Project Role"; - description: "Removes the role from the project and on every resource it has a dependency. This includes project grants and user grants." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3737,6 +3793,11 @@ service ManagementService { }; } + // Get Project Grant By ID + // + // Deprecated: [List Project Grants](apis/resources/project_service_v2/project-service-list-project-grants.api.mdx) to get a project grant. + // + // Returns a project grant. A project grant is when the organization grants its project to another organization. rpc GetProjectGrantByID(GetProjectGrantByIDRequest) returns (GetProjectGrantByIDResponse) { option (google.api.http) = { get: "/projects/{project_id}/grants/{grant_id}" @@ -3748,8 +3809,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - summary: "Project Grant By ID"; - description: "Returns a project grant. A project grant is when the organization grants its project to another organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3761,6 +3821,11 @@ service ManagementService { }; } + // List Project Grants + // + // Deprecated: [List Project Grants](apis/resources/project_service_v2/project-service-list-project-grants.api.mdx) to list project grants. + // + // Returns a list of project grants for a specific project. A project grant is when the organization grants its project to another organization. rpc ListProjectGrants(ListProjectGrantsRequest) returns (ListProjectGrantsResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants/_search" @@ -3774,8 +3839,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - summary: "Search Project Grants from Project"; - description: "Returns a list of project grants for a specific project. A project grant is when the organization grants its project to another organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3787,6 +3851,11 @@ service ManagementService { }; } + // Search Project Grants + // + // Deprecated: [List Project Grants](apis/resources/project_service_v2/project-service-list-project-grants.api.mdx) to list project grants. + // + // Returns a list of project grants. A project grant is when the organization grants its project to another organization. rpc ListAllProjectGrants(ListAllProjectGrantsRequest) returns (ListAllProjectGrantsResponse) { option (google.api.http) = { post: "/projectgrants/_search" @@ -3799,8 +3868,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - summary: "Search Project Grants"; - description: "Returns a list of project grants. A project grant is when the organization grants its project to another organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3812,6 +3880,11 @@ service ManagementService { }; } + // Add Project Grant + // + // Deprecated: [Create Project Grant](apis/resources/project_service_v2/project-service-create-project-grant.api.mdx) to add a project grant. + // + // Grant a project to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization. rpc AddProjectGrant(AddProjectGrantRequest) returns (AddProjectGrantResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants" @@ -3824,8 +3897,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - summary: "Add Project Grant"; - description: "Grant a project to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization" + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3837,6 +3909,11 @@ service ManagementService { }; } + // Update Project Grant + // + // Deprecated: [Update Project Grant](apis/resources/project_service_v2/project-service-update-project-grant.api.mdx) to update a project grant. + // + // Change the roles of the project that is granted to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization. rpc UpdateProjectGrant(UpdateProjectGrantRequest) returns (UpdateProjectGrantResponse) { option (google.api.http) = { put: "/projects/{project_id}/grants/{grant_id}" @@ -3849,8 +3926,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - summary: "Change Project Grant"; - description: "Change the roles of the project that is granted to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization" + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3862,6 +3938,11 @@ service ManagementService { }; } + // Deactivate Project Grant + // + // Deprecated: [Deactivate Project Grant](apis/resources/project_service_v2/project-service-deactivate-project-grant.api.mdx) to deactivate a project grant. + // + // Set the state of the project grant to deactivated. The grant has to be active to be able to deactivate. rpc DeactivateProjectGrant(DeactivateProjectGrantRequest) returns (DeactivateProjectGrantResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants/{grant_id}/_deactivate" @@ -3874,8 +3955,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - summary: "Deactivate Project Grant"; - description: "Set the state of the project grant to deactivated. The grant has to be active to be able to deactivate." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3887,6 +3967,11 @@ service ManagementService { }; } + // Reactivate Project Grant + // + // Deprecated: [Activate Project Grant](apis/resources/project_service_v2/project-service-activate-project-grant.api.mdx) to activate a project grant. + // + // Set the state of the project grant to active. The grant has to be deactivated to be able to reactivate. rpc ReactivateProjectGrant(ReactivateProjectGrantRequest) returns (ReactivateProjectGrantResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants/{grant_id}/_reactivate" @@ -3899,8 +3984,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - summary: "Reactivate Project Grant"; - description: "Set the state of the project grant to active. The grant has to be deactivated to be able to reactivate." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -3912,6 +3996,11 @@ service ManagementService { }; } + // Remove Project Grant + // + // Deprecated: [Delete Project Grant](apis/resources/project_service_v2/project-service-delete-project-grant.api.mdx) to remove a project grant. + // + // Remove a project grant. All user grants for this project grant will also be removed. A user will not have access to the project afterward (if permissions are checked). rpc RemoveProjectGrant(RemoveProjectGrantRequest) returns (RemoveProjectGrantResponse) { option (google.api.http) = { delete: "/projects/{project_id}/grants/{grant_id}" @@ -3923,8 +4012,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - summary: "Remove Project Grant"; - description: "Remove a project grant. All user grants for this project grant will also be removed. A user will not have access to the project afterward (if permissions are checked)." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; diff --git a/proto/zitadel/project/v2beta/project_service.proto b/proto/zitadel/project/v2beta/project_service.proto new file mode 100644 index 0000000000..cb7110bc91 --- /dev/null +++ b/proto/zitadel/project/v2beta/project_service.proto @@ -0,0 +1,1237 @@ +syntax = "proto3"; + +package zitadel.project.v2beta; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +import "zitadel/project/v2beta/query.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/filter/v2beta/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/project/v2beta;project"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Project Service"; + version: "2.0-beta"; + description: "This API is intended to manage Projects in a ZITADEL Organization. This service is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage projects. +service ProjectService { + + // Create Project + // + // Create a new Project. + // + // Required permission: + // - `project.create` + rpc CreateProject (CreateProjectRequest) returns (CreateProjectResponse) { + option (google.api.http) = { + post: "/v2beta/projects" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project created successfully"; + }; + }; + responses: { + key: "409" + value: { + description: "The project to create already exists."; + } + }; + }; + } + + // Update Project + // + // Update an existing project. + // + // Required permission: + // - `project.write` + rpc UpdateProject (UpdateProjectRequest) returns (UpdateProjectResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project successfully updated or left unchanged"; + }; + }; + responses: { + key: "404" + value: { + description: "The project to update does not exist."; + } + }; + }; + } + + // Delete Project + // + // Delete an existing project. + // In case the project is not found, the request will return a successful response as + // the desired state is already achieved. + // + // Required permission: + // - `project.delete` + rpc DeleteProject (DeleteProjectRequest) returns (DeleteProjectResponse) { + option (google.api.http) = { + delete: "/v2beta/projects/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project deleted successfully"; + }; + }; + }; + } + + // Get Project + // + // Returns the project identified by the requested ID. + // + // Required permission: + // - `project.read` + rpc GetProject (GetProjectRequest) returns (GetProjectResponse) { + option (google.api.http) = { + get: "/v2beta/projects/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "project.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Project retrieved successfully"; + } + }; + responses: { + key: "404" + value: { + description: "The project to get does not exist."; + } + }; + }; + } + + // List Projects + // + // List all matching projects. By default all projects of the instance that the caller has permission to read are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - `project.read` + rpc ListProjects (ListProjectsRequest) returns (ListProjectsResponse) { + option (google.api.http) = { + post: "/v2beta/projects/search", + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "project.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all projects matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Deactivate Project + // + // Set the state of a project to deactivated. Request returns no error if the project is already deactivated. + // Applications under deactivated projects are not able to login anymore. + // + // Required permission: + // - `project.write` + rpc DeactivateProject (DeactivateProjectRequest) returns (DeactivateProjectResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{id}/deactivate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project deactivated successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "The project to deactivate does not exist."; + } + }; + }; + } + + // Activate Project + // + // Set the state of a project to active. Request returns no error if the project is already activated. + // + // Required permission: + // - `project.write` + rpc ActivateProject (ActivateProjectRequest) returns (ActivateProjectResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{id}/activate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project activated successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "The project to activate does not exist."; + } + }; + }; + } + + // Add Project Role + // + // Add a new project role to a project. The key must be unique within the project. + // + // Required permission: + // - `project.role.write` + rpc AddProjectRole (AddProjectRoleRequest) returns (AddProjectRoleResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{project_id}/roles" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project role added successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "The project to add the project role does not exist."; + } + }; + }; + } + + // Update Project Role + // + // Change a project role. The key is not editable. If a key should change, remove the role and create a new one. + // + // Required permission: + // - `project.role.write` + rpc UpdateProjectRole (UpdateProjectRoleRequest) returns (UpdateProjectRoleResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{project_id}/roles/{role_key}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project role updated successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "The project role to update does not exist."; + } + }; + }; + } + + // Remove Project Role + // + // Removes the role from the project and on every resource it has a dependency. This includes project grants and user grants. + // + // Required permission: + // - `project.role.write` + rpc RemoveProjectRole (RemoveProjectRoleRequest) returns (RemoveProjectRoleResponse) { + option (google.api.http) = { + delete: "/v2beta/projects/{project_id}/roles/{role_key}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project role removed successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "The project role to remove does not exist."; + } + }; + }; + } + + // List Project Roles + // + // Returns all roles of a project matching the search query. + // + // Required permission: + // - `project.role.read` + rpc ListProjectRoles (ListProjectRolesRequest) returns (ListProjectRolesResponse) { + option (google.api.http) = { + delete: "/v2beta/projects/{project_id}/roles/search" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "project.role.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all project roles matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Create Project Grant + // + // Grant a project to another organization. + // The project grant will allow the granted organization to access the project and manage the authorizations for its users. + // + // Required permission: + // - `project.grant.create` + rpc CreateProjectGrant (CreateProjectGrantRequest) returns (CreateProjectGrantResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{project_id}/grants" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project grant created successfully"; + }; + }; + responses: { + key: "409" + value: { + description: "The project grant to create already exists."; + } + }; + }; + } + + // Update Project Grant + // + // Change the roles of the project that is granted to another organization. + // The project grant will allow the granted organization to access the project and manage the authorizations for its users. + // + // Required permission: + // - `project.grant.write` + rpc UpdateProjectGrant (UpdateProjectGrantRequest) returns (UpdateProjectGrantResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{project_id}/grants/{granted_organization_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project grant successfully updated or left unchanged"; + }; + }; + responses: { + key: "404" + value: { + description: "The project grant to update does not exist."; + } + }; + }; + } + + // Delete Project Grant + // + // Delete a project grant. All user grants for this project grant will also be removed. + // A user will not have access to the project afterward (if permissions are checked). + // In case the project grant is not found, the request will return a successful response as + // the desired state is already achieved. + // + // Required permission: + // - `project.grant.delete` + rpc DeleteProjectGrant (DeleteProjectGrantRequest) returns (DeleteProjectGrantResponse) { + option (google.api.http) = { + delete: "/v2beta/projects/{project_id}/grants/{granted_organization_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project grant deleted successfully"; + }; + }; + }; + } + + // Deactivate Project Grant + // + // Set the state of the project grant to deactivated. + // Applications under deactivated projects grants are not able to login anymore. + // + // Required permission: + // - `project.grant.write` + rpc DeactivateProjectGrant(DeactivateProjectGrantRequest) returns (DeactivateProjectGrantResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{project_id}/grants/{granted_organization_id}/deactivate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project grant deactivated successfully"; + }; + }; + }; + } + + // Activate Project Grant + // + // Set the state of the project grant to activated. + // + // Required permission: + // - `project.grant.write` + rpc ActivateProjectGrant(ActivateProjectGrantRequest) returns (ActivateProjectGrantResponse) { + option (google.api.http) = { + post: "/v2beta/projects/{project_id}/grants/{granted_organization_id}/activate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Project grant activated successfully"; + }; + }; + }; + } + + // List Project Grants + // + // Returns a list of project grants. A project grant is when the organization grants its project to another organization. + // + // Required permission: + // - `project.grant.write` + rpc ListProjectGrants(ListProjectGrantsRequest) returns (ListProjectGrantsResponse) { + option (google.api.http) = { + post: "/v2beta/projects/grants/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "project.grant.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all project grants matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } +} + +message CreateProjectRequest { + // The unique identifier of the organization the project belongs to. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + // The unique identifier of the project. + optional string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + // Name of the project. + string name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"MyProject\""; + } + ]; + // Enable this setting to have role information included in the user info endpoint. It is also dependent on your application settings to include it in tokens and other types. + bool project_role_assertion = 4; + // When enabled ZITADEL will check if a user has an authorization to use this project assigned when login into an application of this project. + bool authorization_required = 5; + // When enabled ZITADEL will check if the organization of the user, that is trying to log in, has access to this project (either owns the project or is granted). + bool project_access_required = 6; + // Define which private labeling/branding should trigger when getting to a login of this project. + PrivateLabelingSetting private_labeling_setting = 7 [ + (validate.rules).enum = {defined_only: true} + ]; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"organizationId\":\"69629026806489455\",\"name\":\"MyProject\",\"projectRoleAssertion\":true,\"projectRoleCheck\":true,\"hasProjectCheck\":true,\"privateLabelingSetting\":\"PRIVATE_LABELING_SETTING_UNSPECIFIED\"}"; + }; +} + +message CreateProjectResponse { + // The unique identifier of the newly created project. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the project creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message UpdateProjectRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + // Name of the project. + optional string name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"MyProject-Updated\""; + } + ]; + // Enable this setting to have role information included in the user info endpoint. It is also dependent on your application settings to include it in tokens and other types. + optional bool project_role_assertion = 3; + // When enabled ZITADEL will check if a user has a role of this project assigned when login into an application of this project. + optional bool project_role_check = 4; + // When enabled ZITADEL will check if the organization of the user, that is trying to log in, has a grant to this project. + optional bool has_project_check = 5; + // Define which private labeling/branding should trigger when getting to a login of this project. + optional PrivateLabelingSetting private_labeling_setting = 6 [ + (validate.rules).enum = {defined_only: true} + ]; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"name\":\"MyProject-Updated\",\"projectRoleAssertion\":true,\"projectRoleCheck\":true,\"hasProjectCheck\":true,\"privateLabelingSetting\":\"PRIVATE_LABELING_SETTING_UNSPECIFIED\"}"; + }; +} + +message UpdateProjectResponse { + // The timestamp of the change of the project. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeleteProjectRequest { + // The unique identifier of the project. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message DeleteProjectResponse { + // The timestamp of the deletion of the project. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might be empty. + google.protobuf.Timestamp deletion_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GetProjectRequest { + // The unique identifier of the project. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message GetProjectResponse { + Project project = 1; +} + +message ListProjectsRequest { + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional ProjectFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"PROJECT_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Define the criteria to query for. + repeated ProjectSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"sortingColumn\":\"PROJECT_FIELD_NAME_CREATION_DATE\",\"filters\":[{\"projectNameFilter\":{\"projectName\":\"MyProject\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"inProjectIdsFilter\":{\"projectIds\":[\"69629023906488334\",\"69622366012355662\"]}}]}"; + }; +} + +message ListProjectsResponse { + zitadel.filter.v2beta.PaginationResponse pagination = 1; + repeated Project projects = 2; +} + +message DeactivateProjectRequest { + // The unique identifier of the project. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message DeactivateProjectResponse { + // The timestamp of the change of the project. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ActivateProjectRequest { + // The unique identifier of the project. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message ActivateProjectResponse { + // The timestamp of the change of the project. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message AddProjectRoleRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // The key is the only relevant attribute for ZITADEL regarding the authorization checks. + string role_key = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"ADMIN\""; + } + ]; + // Name displayed for the role. + string display_name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Administrator\""; + } + ]; + // The group is only used for display purposes. That you have better handling, like giving all the roles from a group to a user. + optional string group = 4 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Admins\""; + } + ]; +} + +message AddProjectRoleResponse { + // The timestamp of the project role creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message UpdateProjectRoleRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // The key is the only relevant attribute for ZITADEL regarding the authorization checks. + string role_key = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"ADMIN\""; + } + ]; + // Name displayed for the role. + optional string display_name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Administrator\""; + } + ]; + // The group is only used for display purposes. That you have better handling, like giving all the roles from a group to a user. + optional string group = 4 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Admins\""; + } + ]; +} + +message UpdateProjectRoleResponse { + // The timestamp of the change of the project role. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RemoveProjectRoleRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // The key is the only relevant attribute for ZITADEL regarding the authorization checks. + string role_key = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"ADMIN\""; + } + ]; +} + +message RemoveProjectRoleResponse { + // The timestamp of the removal of the project role. + // Note that the removal date is only guaranteed to be set if the removal was successful during the request. + // In case the removal occurred in a previous request, the removal date might be empty. + google.protobuf.Timestamp removal_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListProjectRolesRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 2; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional ProjectRoleFieldName sorting_column = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"PROJECT_ROLE_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Define the criteria to query for. + repeated ProjectRoleSearchFilter filters = 4; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"sortingColumn\":\"PROJECT_ROLE_FIELD_NAME_CREATION_DATE\",\"filters\":[{\"keyFilter\":{\"key\":\"role.super.man\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"displayNameFilter\":{\"displayName\":\"SUPER\"}}]}"; + }; +} + +message ListProjectRolesResponse { + zitadel.filter.v2beta.PaginationResponse pagination = 1; + repeated ProjectRole project_roles = 2; +} + + +message CreateProjectGrantRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // Organization the project is granted to. + string granted_organization_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"28746028909593987\"" + } + ]; + // Keys of the role available for the project grant. + repeated string role_keys = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"RoleKey1\", \"RoleKey2\"]"; + } + ]; +} + +message CreateProjectGrantResponse { + // The timestamp of the project grant creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message UpdateProjectGrantRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // Organization the project is granted to. + string granted_organization_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"28746028909593987\"" + } + ]; + // Keys of the role available for the project grant. + repeated string role_keys = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"RoleKey1\", \"RoleKey2\"]"; + } + ]; +} + +message UpdateProjectGrantResponse { + // The timestamp of the change of the project grant. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeleteProjectGrantRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // Organization the project is granted to. + string granted_organization_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"28746028909593987\"" + } + ]; +} + +message DeleteProjectGrantResponse { + // The timestamp of the deletion of the project grant. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might be empty. + google.protobuf.Timestamp deletion_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeactivateProjectGrantRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // Organization the project is granted to. + string granted_organization_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"28746028909593987\"" + } + ]; +} + +message DeactivateProjectGrantResponse { + // The timestamp of the change of the project grant. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ActivateProjectGrantRequest { + // ID of the project. + string project_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629026806489455\""; + } + ]; + // Organization the project is granted to. + string granted_organization_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"28746028909593987\"" + } + ]; +} + +message ActivateProjectGrantResponse { + // The timestamp of the change of the project grant. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListProjectGrantsRequest { + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional ProjectGrantFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"PROJECT_GRANT_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Define the criteria to query for. + repeated ProjectGrantSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":0,\"asc\":true},\"filters\":[{\"projectNameFilter\":{\"projectName\":\"MyProject\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"inProjectIdsFilter\":{\"projectIds\":[\"69629023906488334\",\"69622366012355662\"]}}]}"; + }; +} + +message ListProjectGrantsResponse { + zitadel.filter.v2beta.PaginationResponse pagination = 1; + repeated ProjectGrant project_grants = 2; +} \ No newline at end of file diff --git a/proto/zitadel/project/v2beta/query.proto b/proto/zitadel/project/v2beta/query.proto new file mode 100644 index 0000000000..f328b65189 --- /dev/null +++ b/proto/zitadel/project/v2beta/query.proto @@ -0,0 +1,347 @@ +syntax = "proto3"; + +package zitadel.project.v2beta; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/project/v2beta;project"; + +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +import "zitadel/filter/v2beta/filter.proto"; + +message ProjectGrant { + // The unique identifier of the organization which granted the project to the granted_organization_id. + string organization_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the granted project creation. + google.protobuf.Timestamp creation_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change to the granted project (e.g. creation, activation, deactivation). + google.protobuf.Timestamp change_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // The ID of the organization the project is granted to. + string granted_organization_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + // The name of the organization the project is granted to. + string granted_organization_name = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Some Organization\"" + } + ]; + // The roles of the granted project. + repeated string granted_role_keys = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"role.super.man\"]" + } + ]; + // The ID of the granted project. + string project_id = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + // The name of the granted project. + string project_name = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\"" + } + ]; + // Describes the current state of the granted project. + ProjectGrantState state = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the project"; + } + ]; +} + +enum ProjectGrantState { + PROJECT_GRANT_STATE_UNSPECIFIED = 0; + PROJECT_GRANT_STATE_ACTIVE = 1; + PROJECT_GRANT_STATE_INACTIVE = 2; +} + +message Project { + // The unique identifier of the project. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the organization the project belongs to. + string organization_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the project creation. + google.protobuf.Timestamp creation_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change to the project (e.g. creation, activation, deactivation). + google.protobuf.Timestamp change_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // The name of the project. + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ip_allow_list\""; + } + ]; + // Describes the current state of the project. + ProjectState state = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the project"; + } + ]; + // Describes if the roles of the user should be added to the token. + bool project_role_assertion = 7; + // When enabled ZITADEL will check if a user has an authorization to use this project assigned when login into an application of this project. + bool authorization_required = 8; + // When enabled ZITADEL will check if the organization of the user, that is trying to log in, has access to this project (either owns the project or is granted). + bool project_access_required = 9; + // Defines from where the private labeling should be triggered. + PrivateLabelingSetting private_labeling_setting = 10; + + // The ID of the organization the project is granted to. + optional string granted_organization_id = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + // The name of the organization the project is granted to. + optional string granted_organization_name = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Some Organization\"" + } + ]; + // Describes the current state of the granted project. + GrantedProjectState granted_state = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the granted project"; + } + ]; +} + +enum ProjectState { + PROJECT_STATE_UNSPECIFIED = 0; + PROJECT_STATE_ACTIVE = 1; + PROJECT_STATE_INACTIVE = 2; +} + +enum GrantedProjectState { + GRANTED_PROJECT_STATE_UNSPECIFIED = 0; + GRANTED_PROJECT_STATE_ACTIVE = 1; + GRANTED_PROJECT_STATE_INACTIVE = 2; +} + +enum PrivateLabelingSetting { + PRIVATE_LABELING_SETTING_UNSPECIFIED = 0; + PRIVATE_LABELING_SETTING_ENFORCE_PROJECT_RESOURCE_OWNER_POLICY = 1; + PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY = 2; +} + +enum ProjectFieldName { + PROJECT_FIELD_NAME_UNSPECIFIED = 0; + PROJECT_FIELD_NAME_ID = 1; + PROJECT_FIELD_NAME_CREATION_DATE = 2; + PROJECT_FIELD_NAME_CHANGE_DATE = 3; + PROJECT_FIELD_NAME_NAME = 4; +} + +enum ProjectGrantFieldName { + PROJECT_GRANT_FIELD_NAME_UNSPECIFIED = 0; + PROJECT_GRANT_FIELD_NAME_PROJECT_ID = 1; + PROJECT_GRANT_FIELD_NAME_CREATION_DATE = 2; + PROJECT_GRANT_FIELD_NAME_CHANGE_DATE = 3; +} + +enum ProjectRoleFieldName { + PROJECT_ROLE_FIELD_NAME_UNSPECIFIED = 0; + PROJECT_ROLE_FIELD_NAME_KEY = 1; + PROJECT_ROLE_FIELD_NAME_CREATION_DATE = 2; + PROJECT_ROLE_FIELD_NAME_CHANGE_DATE = 3; +} + +message ProjectSearchFilter { + oneof filter { + option (validate.required) = true; + + ProjectNameFilter project_name_filter = 1; + InProjectIDsFilter in_project_ids_filter = 2; + ProjectResourceOwnerFilter project_resource_owner_filter = 3; + ProjectGrantResourceOwnerFilter project_grant_resource_owner_filter = 4; + ProjectOrganizationIDFilter project_organization_id_filter = 5; + } +} + +message ProjectNameFilter { + // Defines the name of the project to query for. + string project_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"ip_allow_list\""; + } + ]; + // Defines which text comparison method used for the name query. + zitadel.filter.v2beta.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message InProjectIDsFilter { + // Defines the ids to query for. + repeated string project_ids = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the ids of the projects to include" + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} + +message ProjectResourceOwnerFilter { + // Defines the ID of organization the project belongs to query for. + string project_resource_owner = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; +} + +message ProjectGrantResourceOwnerFilter { + // Defines the ID of organization the project grant belongs to query for. + string project_grant_resource_owner = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; +} + +message ProjectOrganizationIDFilter { + // Defines the ID of organization the project and granted project belong to query for. + string project_organization_id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; +} + +message ProjectGrantSearchFilter { + oneof filter { + option (validate.required) = true; + + ProjectNameFilter project_name_filter = 1; + ProjectRoleKeyFilter role_key_filter = 2; + InProjectIDsFilter in_project_ids_filter = 3; + ProjectResourceOwnerFilter project_resource_owner_filter = 4; + ProjectGrantResourceOwnerFilter project_grant_resource_owner_filter = 5; + } +} + +message GrantedOrganizationIDFilter { + // Defines the ID of organization the project is granted to query for. + string granted_organization_id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; +} + +message ProjectRole { + // ID of the project. + string project_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629026806489455\""; + } + ]; + // Key of the project role. + string key = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"role.super.man\"" + } + ]; + // The timestamp of the project role creation. + google.protobuf.Timestamp creation_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change to the project role. + google.protobuf.Timestamp change_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // Display name of the project role. + string display_name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Super man\"" + } + ]; + // Group of the project role. + string group = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"people\"" + } + ]; +} + +message ProjectRoleSearchFilter { + oneof filter { + option (validate.required) = true; + + ProjectRoleKeyFilter role_key_filter = 1; + ProjectRoleDisplayNameFilter display_name_filter = 2; + } +} + +message ProjectRoleKeyFilter { + string key = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"role.super.man\"" + } + ]; + // Defines which text comparison method used for the name query. + zitadel.filter.v2beta.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +message ProjectRoleDisplayNameFilter { + string display_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"SUPER\"" + } + ]; + // Defines which text comparison method used for the name query. + zitadel.filter.v2beta.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} \ No newline at end of file From 2cf3ef4de4ef993367daec6ff3974bdbdf70d2f3 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 23 May 2025 13:52:25 +0200 Subject: [PATCH 064/123] feat: federated logout for SAML IdPs (#9931) # Which Problems Are Solved Currently if a user signs in using an IdP, once they sign out of Zitadel, the corresponding IdP session is not terminated. This can be the desired behavior. In some cases, e.g. when using a shared computer it results in a potential security risk, since a follower user might be able to sign in as the previous using the still open IdP session. # How the Problems Are Solved - Admins can enabled a federated logout option on SAML IdPs through the Admin and Management APIs. - During the termination of a login V1 session using OIDC end_session endpoint, Zitadel will check if an IdP was used to authenticate that session. - In case there was a SAML IdP used with Federated Logout enabled, it will intercept the logout process, store the information into the shared cache and redirect to the federated logout endpoint in the V1 login. - The V1 login federated logout endpoint checks every request on an existing cache entry. On success it will create a SAML logout request for the used IdP and either redirect or POST to the configured SLO endpoint. The cache entry is updated with a `redirected` state. - A SLO endpoint is added to the `/idp` handlers, which will handle the SAML logout responses. At the moment it will check again for an existing federated logout entry (with state `redirected`) in the cache. On success, the user is redirected to the initially provided `post_logout_redirect_uri` from the end_session request. # Additional Changes None # Additional Context - This PR merges the https://github.com/zitadel/zitadel/pull/9841 and https://github.com/zitadel/zitadel/pull/9854 to main, additionally updating the docs on Entra ID SAML. - closes #9228 - backport to 3.x --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> Co-authored-by: Zach Hirschtritt --- cmd/defaults.yaml | 10 ++ cmd/setup/56.go | 27 ++++ cmd/setup/56.sql | 1 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + cmd/start/start.go | 30 ++++- .../provider-oidc.component.html | 13 +- .../provider-saml-sp.component.html | 11 +- .../provider-saml-sp.component.scss | 10 +- .../provider-saml-sp.component.ts | 7 + console/src/assets/i18n/bg.json | 3 +- console/src/assets/i18n/cs.json | 2 + console/src/assets/i18n/de.json | 2 + console/src/assets/i18n/en.json | 2 + console/src/assets/i18n/es.json | 2 + console/src/assets/i18n/fr.json | 2 + console/src/assets/i18n/hu.json | 2 + console/src/assets/i18n/id.json | 2 + console/src/assets/i18n/it.json | 2 + console/src/assets/i18n/ja.json | 2 + console/src/assets/i18n/ko.json | 2 + console/src/assets/i18n/mk.json | 2 + console/src/assets/i18n/nl.json | 2 + console/src/assets/i18n/pl.json | 2 + console/src/assets/i18n/pt.json | 2 + console/src/assets/i18n/ro.json | 2 + console/src/assets/i18n/ru.json | 2 + console/src/assets/i18n/sv.json | 2 + console/src/assets/i18n/zh.json | 2 + .../identity-providers/azure-ad-saml.mdx | 5 +- internal/api/grpc/admin/idp_converter.go | 2 + internal/api/grpc/idp/converter.go | 1 + internal/api/grpc/idp/v2/query.go | 1 + internal/api/grpc/management/idp_converter.go | 2 + internal/api/idp/idp.go | 44 +++++++ internal/api/oidc/auth_request.go | 63 ++++++++- internal/api/oidc/op.go | 19 ++- .../api/ui/login/external_provider_handler.go | 121 ++++++++++++++++++ internal/api/ui/login/login.go | 30 +++-- internal/api/ui/login/router.go | 2 + .../eventsourcing/eventstore/user.go | 5 + .../eventsourcing/view/user_session.go | 4 + internal/auth/repository/user.go | 2 + internal/cache/cache.go | 1 + internal/cache/connector/connector.go | 1 + internal/cache/purpose_enumer.go | 12 +- internal/command/idp.go | 1 + internal/command/idp_intent_test.go | 2 + internal/command/idp_model.go | 10 ++ internal/command/instance_idp.go | 3 + internal/command/instance_idp_model.go | 2 + internal/command/instance_idp_test.go | 8 ++ internal/command/org_idp.go | 3 + internal/command/org_idp_model.go | 2 + internal/command/org_idp_test.go | 8 ++ internal/domain/federatedlogout/logout.go | 37 ++++++ internal/query/idp_template.go | 13 ++ internal/query/idp_template_test.go | 25 ++++ internal/query/projection/idp_template.go | 6 + .../query/projection/idp_template_test.go | 18 ++- internal/repository/idp/saml.go | 10 ++ internal/repository/instance/idp.go | 2 + internal/repository/org/idp.go | 2 + .../user/repository/view/user_session.sql | 29 +++++ .../user/repository/view/user_session_view.go | 19 ++- proto/zitadel/admin.proto | 6 + proto/zitadel/idp.proto | 3 + proto/zitadel/idp/v2/idp.proto | 3 + proto/zitadel/management.proto | 6 + 69 files changed, 633 insertions(+), 51 deletions(-) create mode 100644 cmd/setup/56.go create mode 100644 cmd/setup/56.sql create mode 100644 internal/domain/federatedlogout/logout.go create mode 100644 internal/user/repository/view/user_session.sql diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 397e9af376..7bb44b743f 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -312,6 +312,16 @@ Caches: AddSource: true Formatter: Format: text + # Federated logouts store the information needed to handle federated logout and their state transfer + FederatedLogouts: + Connector: "postgres" + MaxAge: 1h + LastUseAge: 10m + Log: + Level: error + AddSource: true + Formatter: + Format: text Machine: # Cloud-hosted VMs need to specify their metadata endpoint so that the machine can be uniquely identified. diff --git a/cmd/setup/56.go b/cmd/setup/56.go new file mode 100644 index 0000000000..72ccb5e6ff --- /dev/null +++ b/cmd/setup/56.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 56.sql + addSAMLFederatedLogout string +) + +type IDPTemplate6SAMLFederatedLogout struct { + dbClient *database.DB +} + +func (mig *IDPTemplate6SAMLFederatedLogout) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addSAMLFederatedLogout) + return err +} + +func (mig *IDPTemplate6SAMLFederatedLogout) String() string { + return "56_idp_templates6_add_saml_federated_logout" +} diff --git a/cmd/setup/56.sql b/cmd/setup/56.sql new file mode 100644 index 0000000000..f5544eddf5 --- /dev/null +++ b/cmd/setup/56.sql @@ -0,0 +1 @@ +ALTER TABLE IF EXISTS projections.idp_templates6_saml ADD COLUMN IF NOT EXISTS federated_logout_enabled BOOLEAN DEFAULT FALSE; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 5e5c842b14..bd2abde9ea 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -152,6 +152,7 @@ type Steps struct { s53InitPermittedOrgsFunction *InitPermittedOrgsFunction53 s54InstancePositionIndex *InstancePositionIndex s55ExecutionHandlerStart *ExecutionHandlerStart + s56IDPTemplate6SAMLFederatedLogout *IDPTemplate6SAMLFederatedLogout } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 58bc89d2e4..c84976f282 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -214,6 +214,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s53InitPermittedOrgsFunction = &InitPermittedOrgsFunction53{dbClient: dbClient} steps.s54InstancePositionIndex = &InstancePositionIndex{dbClient: dbClient} steps.s55ExecutionHandlerStart = &ExecutionHandlerStart{dbClient: dbClient} + steps.s56IDPTemplate6SAMLFederatedLogout = &IDPTemplate6SAMLFederatedLogout{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -258,6 +259,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s53InitPermittedOrgsFunction, steps.s54InstancePositionIndex, steps.s55ExecutionHandlerStart, + steps.s56IDPTemplate6SAMLFederatedLogout, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/start/start.go b/cmd/start/start.go index 1d83197062..af76b29e99 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -74,12 +74,14 @@ import ( "github.com/zitadel/zitadel/internal/authz" authz_repo "github.com/zitadel/zitadel/internal/authz/repository" authz_es "github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/eventstore" + "github.com/zitadel/zitadel/internal/cache" "github.com/zitadel/zitadel/internal/cache/connector" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" cryptoDB "github.com/zitadel/zitadel/internal/crypto/database" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/domain/federatedlogout" "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" @@ -511,7 +513,12 @@ func startAPIs( assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.SystemAuthZ, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) - apis.RegisterHandlerOnPrefix(idp.HandlerPrefix, idp.NewHandler(commands, queries, keys.IDPConfig, instanceInterceptor.Handler)) + federatedLogoutsCache, err := connector.StartCache[federatedlogout.Index, string, *federatedlogout.FederatedLogout](ctx, []federatedlogout.Index{federatedlogout.IndexRequestID}, cache.PurposeFederatedLogout, cacheConnectors.Config.FederatedLogouts, cacheConnectors) + if err != nil { + return nil, err + } + + apis.RegisterHandlerOnPrefix(idp.HandlerPrefix, idp.NewHandler(commands, queries, keys.IDPConfig, instanceInterceptor.Handler, federatedLogoutsCache)) userAgentInterceptor, err := middleware.NewUserAgentHandler(config.UserAgentCookie, keys.UserAgentCookieKey, id.SonyFlakeGenerator(), config.ExternalSecure, login.EndpointResources, login.EndpointExternalLoginCallbackFormPost, login.EndpointSAMLACS) if err != nil { @@ -532,7 +539,25 @@ func startAPIs( } apis.RegisterHandlerOnPrefix(openapi.HandlerPrefix, openAPIHandler) - 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) + 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, + federatedLogoutsCache, + ) if err != nil { return nil, fmt.Errorf("unable to start oidc provider: %w", err) } @@ -581,6 +606,7 @@ func startAPIs( keys.IDPConfig, keys.CSRFCookieKey, cacheConnectors, + federatedLogoutsCache, ) if err != nil { return nil, fmt.Errorf("unable to start login: %w", err) diff --git a/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html b/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html index e326d3be26..c7bb48ea71 100644 --- a/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html +++ b/console/src/app/modules/providers/provider-oidc/provider-oidc.component.html @@ -98,13 +98,12 @@

{{ 'IDP.ISIDTOKENMAPPING_DESC' | translate }}

{{ 'IDP.ISIDTOKENMAPPING' | translate }} - - - -
-

{{ 'IDP.USEPKCE_DESC' | translate }}

- {{ 'IDP.USEPKCE' | translate }} -
+ +
+

{{ 'IDP.USEPKCE_DESC' | translate }}

+ {{ 'IDP.USEPKCE' | translate }} +
+
diff --git a/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html b/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html index d738b3fec9..245a963fa8 100644 --- a/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html +++ b/console/src/app/modules/providers/provider-saml-sp/provider-saml-sp.component.html @@ -82,7 +82,7 @@
-

{{ 'IDP.SAML.TRANSIENTMAPPINGATTRIBUTENAME_DESC' | translate }}

+

{{ 'IDP.SAML.TRANSIENTMAPPINGATTRIBUTENAME_DESC' | translate }}

@@ -90,6 +90,15 @@
+ + +
+

{{ 'IDP.FEDERATEDLOGOUTENABLED_DESC' | translate }}

+ {{ + 'IDP.FEDERATEDLOGOUTENABLED' | translate + }} +
+
{{.Form}}`)) +) + +type samlSLOPostData struct { + Form template.HTML +} + +func samlPostLogoutRequest(w http.ResponseWriter, sp crewjam_saml.ServiceProvider, slo, nameID, sessionID string) error { + lr, err := sp.MakeLogoutRequest(slo, nameID) + if err != nil { + return err + } + + return samlSLOPostTemplate.Execute(w, &samlSLOPostData{Form: template.HTML(lr.Post(sessionID))}) +} + +func (l *Login) externalUserID(ctx context.Context, userID, idpID string) (string, error) { + userIDQuery, err := query.NewIDPUserLinksUserIDSearchQuery(userID) + if err != nil { + return "", err + } + idpIDQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpID) + if err != nil { + return "", err + } + links, err := l.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: []query.SearchQuery{userIDQuery, idpIDQuery}}, nil) + if err != nil || len(links.Links) != 1 { + return "", zerrors.ThrowPreconditionFailed(err, "LOGIN-ADK21", "Errors.User.ExternalIDP.NotFound") + } + return links.Links[0].ProvidedUserID, nil +} + // IdPError wraps an error from an external IDP to be able to distinguish it from other errors and to display it // more prominent (popup style) . // It's used if an error occurs during the login process with an external IDP and local authentication is allowed, diff --git a/internal/api/ui/login/login.go b/internal/api/ui/login/login.go index 444c5aaa85..f1ce9bfa2a 100644 --- a/internal/api/ui/login/login.go +++ b/internal/api/ui/login/login.go @@ -21,6 +21,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/domain/federatedlogout" "github.com/zitadel/zitadel/internal/form" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/static" @@ -60,25 +61,20 @@ const ( DefaultLoggedOutPath = HandlerPrefix + EndpointLogoutDone ) -func CreateLogin(config Config, +func CreateLogin( + config Config, command *command.Commands, query *query.Queries, authRepo *eventsourcing.EsRepository, staticStorage static.Storage, consolePath string, - oidcAuthCallbackURL func(context.Context, string) string, - samlAuthCallbackURL func(context.Context, string) string, + oidcAuthCallbackURL, samlAuthCallbackURL func(context.Context, string) string, externalSecure bool, - userAgentCookie, - issuerInterceptor, - oidcInstanceHandler, - samlInstanceHandler, - assetCache, - accessHandler mux.MiddlewareFunc, - userCodeAlg crypto.EncryptionAlgorithm, - idpConfigAlg crypto.EncryptionAlgorithm, + userAgentCookie, issuerInterceptor, oidcInstanceHandler, samlInstanceHandler, assetCache, accessHandler mux.MiddlewareFunc, + userCodeAlg, idpConfigAlg crypto.EncryptionAlgorithm, csrfCookieKey []byte, cacheConnectors connector.Connectors, + federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout], ) (*Login, error) { login := &Login{ oidcAuthCallbackURL: oidcAuthCallbackURL, @@ -101,7 +97,7 @@ func CreateLogin(config Config, login.parser = form.NewParser() var err error - login.caches, err = startCaches(context.Background(), cacheConnectors) + login.caches, err = startCaches(context.Background(), cacheConnectors, federateLogoutCache) if err != nil { return nil, err } @@ -112,7 +108,11 @@ func csp() *middleware.CSP { csp := middleware.DefaultSCP csp.ObjectSrc = middleware.CSPSourceOptsSelf() csp.StyleSrc = csp.StyleSrc.AddNonce() - csp.ScriptSrc = csp.ScriptSrc.AddNonce().AddHash("sha256", "AjPdJSbZmeWHnEc5ykvJFay8FTWeTeRbs9dutfZ0HqE=") + csp.ScriptSrc = csp.ScriptSrc.AddNonce(). + // SAML POST ACS + AddHash("sha256", "AjPdJSbZmeWHnEc5ykvJFay8FTWeTeRbs9dutfZ0HqE="). + // SAML POST SLO + AddHash("sha256", "4Su6mBWzEIFnH4pAGMOuaeBrstwJN4Z3pq/s1Kn4/KQ=") return &csp } @@ -215,14 +215,16 @@ func (l *Login) baseURL(ctx context.Context) string { type Caches struct { idpFormCallbacks cache.Cache[idpFormCallbackIndex, string, *idpFormCallback] + federatedLogouts cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout] } -func startCaches(background context.Context, connectors connector.Connectors) (_ *Caches, err error) { +func startCaches(background context.Context, connectors connector.Connectors, federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout]) (_ *Caches, err error) { caches := new(Caches) caches.idpFormCallbacks, err = connector.StartCache[idpFormCallbackIndex, string, *idpFormCallback](background, []idpFormCallbackIndex{idpFormCallbackIndexRequestID}, cache.PurposeIdPFormCallback, connectors.Config.IdPFormCallbacks, connectors) if err != nil { return nil, err } + caches.federatedLogouts = federateLogoutCache return caches, nil } diff --git a/internal/api/ui/login/router.go b/internal/api/ui/login/router.go index 6e346c9da0..459a0aab5d 100644 --- a/internal/api/ui/login/router.go +++ b/internal/api/ui/login/router.go @@ -14,6 +14,7 @@ const ( EndpointExternalLogin = "/login/externalidp" EndpointExternalLoginCallback = "/login/externalidp/callback" EndpointExternalLoginCallbackFormPost = "/login/externalidp/callback/form" + EndpointExternalLogout = "/logout/externalidp" EndpointSAMLACS = "/login/externalidp/saml/acs" EndpointJWTAuthorize = "/login/jwt/authorize" EndpointJWTCallback = "/login/jwt/callback" @@ -77,6 +78,7 @@ func CreateRouter(login *Login, interceptors ...mux.MiddlewareFunc) *mux.Router router.HandleFunc(EndpointExternalLogin, login.handleExternalLogin).Methods(http.MethodGet) router.HandleFunc(EndpointExternalLoginCallback, login.handleExternalLoginCallback).Methods(http.MethodGet) router.HandleFunc(EndpointExternalLoginCallbackFormPost, login.handleExternalLoginCallbackForm).Methods(http.MethodPost) + router.HandleFunc(EndpointExternalLogout, login.handleExternalLogout).Methods(http.MethodGet) router.HandleFunc(EndpointSAMLACS, login.handleExternalLoginCallback).Methods(http.MethodGet) router.HandleFunc(EndpointSAMLACS, login.handleExternalLoginCallbackForm).Methods(http.MethodPost) router.HandleFunc(EndpointJWTAuthorize, login.handleJWTRequest).Methods(http.MethodGet) diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go index 61895c263d..6c5375a6b9 100644 --- a/internal/auth/repository/eventsourcing/eventstore/user.go +++ b/internal/auth/repository/eventsourcing/eventstore/user.go @@ -13,6 +13,7 @@ import ( "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/user" usr_view "github.com/zitadel/zitadel/internal/user/repository/view" + "github.com/zitadel/zitadel/internal/user/repository/view/model" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -49,6 +50,10 @@ func (repo *UserRepo) UserAgentIDBySessionID(ctx context.Context, sessionID stri return repo.View.UserAgentIDBySessionID(ctx, sessionID, authz.GetInstance(ctx).InstanceID()) } +func (repo *UserRepo) UserSessionByID(ctx context.Context, sessionID string) (*model.UserSessionView, error) { + return repo.View.UserSessionByID(ctx, sessionID, authz.GetInstance(ctx).InstanceID()) +} + func (repo *UserRepo) ActiveUserSessionsBySessionID(ctx context.Context, sessionID string) (userAgentID string, signoutSessions []command.HumanSignOutSession, err error) { userAgentID, sessions, err := repo.View.ActiveUserSessionsBySessionID(ctx, sessionID, authz.GetInstance(ctx).InstanceID()) if err != nil { diff --git a/internal/auth/repository/eventsourcing/view/user_session.go b/internal/auth/repository/eventsourcing/view/user_session.go index a4618e11fb..777bc3213f 100644 --- a/internal/auth/repository/eventsourcing/view/user_session.go +++ b/internal/auth/repository/eventsourcing/view/user_session.go @@ -16,6 +16,10 @@ func (v *View) UserSessionByIDs(ctx context.Context, agentID, userID, instanceID return view.UserSessionByIDs(ctx, v.client, agentID, userID, instanceID) } +func (v *View) UserSessionByID(ctx context.Context, userSessionID, instanceID string) (*model.UserSessionView, error) { + return view.UserSessionByID(ctx, v.client, userSessionID, instanceID) +} + func (v *View) UserSessionsByAgentID(ctx context.Context, agentID, instanceID string) ([]*model.UserSessionView, error) { return view.UserSessionsByAgentID(ctx, v.client, agentID, instanceID) } diff --git a/internal/auth/repository/user.go b/internal/auth/repository/user.go index f09581b32e..b51ca27f24 100644 --- a/internal/auth/repository/user.go +++ b/internal/auth/repository/user.go @@ -4,10 +4,12 @@ import ( "context" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/user/repository/view/model" ) type UserRepository interface { UserSessionsByAgentID(ctx context.Context, agentID string) (sessions []command.HumanSignOutSession, err error) UserAgentIDBySessionID(ctx context.Context, sessionID string) (string, error) + UserSessionByID(ctx context.Context, sessionID string) (*model.UserSessionView, error) ActiveUserSessionsBySessionID(ctx context.Context, sessionID string) (userAgentID string, sessions []command.HumanSignOutSession, err error) } diff --git a/internal/cache/cache.go b/internal/cache/cache.go index dc05208caa..233308a3cb 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -18,6 +18,7 @@ const ( PurposeMilestones PurposeOrganization PurposeIdPFormCallback + PurposeFederatedLogout ) // Cache stores objects with a value of type `V`. diff --git a/internal/cache/connector/connector.go b/internal/cache/connector/connector.go index 1a0534759a..532e795a81 100644 --- a/internal/cache/connector/connector.go +++ b/internal/cache/connector/connector.go @@ -23,6 +23,7 @@ type CachesConfig struct { Milestones *cache.Config Organization *cache.Config IdPFormCallbacks *cache.Config + FederatedLogouts *cache.Config } type Connectors struct { diff --git a/internal/cache/purpose_enumer.go b/internal/cache/purpose_enumer.go index a93a978efb..f721435593 100644 --- a/internal/cache/purpose_enumer.go +++ b/internal/cache/purpose_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _PurposeName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback" +const _PurposeName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callbackfederated_logout" -var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47, 65} +var _PurposeIndex = [...]uint8{0, 11, 25, 35, 47, 65, 81} -const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callback" +const _PurposeLowerName = "unspecifiedauthz_instancemilestonesorganizationid_p_form_callbackfederated_logout" func (i Purpose) String() string { if i < 0 || i >= Purpose(len(_PurposeIndex)-1) { @@ -29,9 +29,10 @@ func _PurposeNoOp() { _ = x[PurposeMilestones-(2)] _ = x[PurposeOrganization-(3)] _ = x[PurposeIdPFormCallback-(4)] + _ = x[PurposeFederatedLogout-(5)] } -var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization, PurposeIdPFormCallback} +var _PurposeValues = []Purpose{PurposeUnspecified, PurposeAuthzInstance, PurposeMilestones, PurposeOrganization, PurposeIdPFormCallback, PurposeFederatedLogout} var _PurposeNameToValueMap = map[string]Purpose{ _PurposeName[0:11]: PurposeUnspecified, @@ -44,6 +45,8 @@ var _PurposeNameToValueMap = map[string]Purpose{ _PurposeLowerName[35:47]: PurposeOrganization, _PurposeName[47:65]: PurposeIdPFormCallback, _PurposeLowerName[47:65]: PurposeIdPFormCallback, + _PurposeName[65:81]: PurposeFederatedLogout, + _PurposeLowerName[65:81]: PurposeFederatedLogout, } var _PurposeNames = []string{ @@ -52,6 +55,7 @@ var _PurposeNames = []string{ _PurposeName[25:35], _PurposeName[35:47], _PurposeName[47:65], + _PurposeName[65:81], } // PurposeString retrieves an enum value from the enum constants string name. diff --git a/internal/command/idp.go b/internal/command/idp.go index 821a577900..06d8f473c0 100644 --- a/internal/command/idp.go +++ b/internal/command/idp.go @@ -122,6 +122,7 @@ type SAMLProvider struct { WithSignedRequest bool NameIDFormat *domain.SAMLNameIDFormat TransientMappingAttributeName string + FederatedLogoutEnabled bool IDPOptions idp.Options } diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 1be3971e87..6cf835f521 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -743,6 +743,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, rep_idp.Options{}, )), ), @@ -763,6 +764,7 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, rep_idp.Options{}, )), ), diff --git a/internal/command/idp_model.go b/internal/command/idp_model.go index 5257d38bf4..188d45e6ec 100644 --- a/internal/command/idp_model.go +++ b/internal/command/idp_model.go @@ -1760,6 +1760,7 @@ type SAMLIDPWriteModel struct { WithSignedRequest bool NameIDFormat *domain.SAMLNameIDFormat TransientMappingAttributeName string + FederatedLogoutEnabled bool idp.Options State domain.IDPState @@ -1788,6 +1789,7 @@ func (wm *SAMLIDPWriteModel) reduceAddedEvent(e *idp.SAMLIDPAddedEvent) { wm.WithSignedRequest = e.WithSignedRequest wm.NameIDFormat = e.NameIDFormat wm.TransientMappingAttributeName = e.TransientMappingAttributeName + wm.FederatedLogoutEnabled = e.FederatedLogoutEnabled wm.Options = e.Options wm.State = domain.IDPStateActive } @@ -1817,6 +1819,9 @@ func (wm *SAMLIDPWriteModel) reduceChangedEvent(e *idp.SAMLIDPChangedEvent) { if e.TransientMappingAttributeName != nil { wm.TransientMappingAttributeName = *e.TransientMappingAttributeName } + if e.FederatedLogoutEnabled != nil { + wm.FederatedLogoutEnabled = *e.FederatedLogoutEnabled + } wm.Options.ReduceChanges(e.OptionChanges) } @@ -1830,6 +1835,7 @@ func (wm *SAMLIDPWriteModel) NewChanges( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) ([]idp.SAMLIDPChanges, error) { changes := make([]idp.SAMLIDPChanges, 0) @@ -1861,6 +1867,9 @@ func (wm *SAMLIDPWriteModel) NewChanges( if wm.TransientMappingAttributeName != transientMappingAttributeName { changes = append(changes, idp.ChangeSAMLTransientMappingAttributeName(transientMappingAttributeName)) } + if wm.FederatedLogoutEnabled != federatedLogoutEnabled { + changes = append(changes, idp.ChangeSAMLFederatedLogoutEnabled(federatedLogoutEnabled)) + } opts := wm.Options.Changes(options) if !opts.IsZero() { changes = append(changes, idp.ChangeSAMLOptions(opts)) @@ -1899,6 +1908,7 @@ func (wm *SAMLIDPWriteModel) ToProvider(callbackURL string, idpAlg crypto.Encryp if wm.TransientMappingAttributeName != "" { opts = append(opts, saml2.WithTransientMappingAttributeName(wm.TransientMappingAttributeName)) } + // TODO: ? if wm.FederatedLogoutEnabled opts = append(opts, saml2.WithCustomRequestTracker( requesttracker.New( addRequest, diff --git a/internal/command/instance_idp.go b/internal/command/instance_idp.go index 348f55cd9c..90efb3edd4 100644 --- a/internal/command/instance_idp.go +++ b/internal/command/instance_idp.go @@ -1795,6 +1795,7 @@ func (c *Commands) prepareAddInstanceSAMLProvider(a *instance.Aggregate, writeMo provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ), }, nil @@ -1848,6 +1849,7 @@ func (c *Commands) prepareUpdateInstanceSAMLProvider(a *instance.Aggregate, writ provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ) if err != nil || event == nil { @@ -1893,6 +1895,7 @@ func (c *Commands) prepareRegenerateInstanceSAMLProviderCertificate(a *instance. writeModel.WithSignedRequest, writeModel.NameIDFormat, writeModel.TransientMappingAttributeName, + writeModel.FederatedLogoutEnabled, writeModel.Options, ) if err != nil || event == nil { diff --git a/internal/command/instance_idp_model.go b/internal/command/instance_idp_model.go index d94c19d318..03d9cd9c36 100644 --- a/internal/command/instance_idp_model.go +++ b/internal/command/instance_idp_model.go @@ -923,6 +923,7 @@ func (wm *InstanceSAMLIDPWriteModel) NewChangedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) (*instance.SAMLIDPChangedEvent, error) { changes, err := wm.SAMLIDPWriteModel.NewChanges( @@ -935,6 +936,7 @@ func (wm *InstanceSAMLIDPWriteModel) NewChangedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ) if err != nil || len(changes) == 0 { diff --git a/internal/command/instance_idp_test.go b/internal/command/instance_idp_test.go index 7002598af5..4805d075dd 100644 --- a/internal/command/instance_idp_test.go +++ b/internal/command/instance_idp_test.go @@ -5437,6 +5437,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, ), ), @@ -5478,6 +5479,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { true, gu.Ptr(domain.SAMLNameIDFormatTransient), "customAttribute", + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5500,6 +5502,7 @@ func TestCommandSide_AddInstanceSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5665,6 +5668,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, )), ), @@ -5703,6 +5707,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), @@ -5718,6 +5723,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { idp.ChangeSAMLWithSignedRequest(true), idp.ChangeSAMLNameIDFormat(gu.Ptr(domain.SAMLNameIDFormatTransient)), idp.ChangeSAMLTransientMappingAttributeName("customAttribute"), + idp.ChangeSAMLFederatedLogoutEnabled(true), idp.ChangeSAMLOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -5742,6 +5748,7 @@ func TestCommandSide_UpdateInstanceGenericSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5845,6 +5852,7 @@ func TestCommandSide_RegenerateInstanceSAMLProviderCertificate(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), diff --git a/internal/command/org_idp.go b/internal/command/org_idp.go index b72fc1fd77..6cae78acc7 100644 --- a/internal/command/org_idp.go +++ b/internal/command/org_idp.go @@ -1768,6 +1768,7 @@ func (c *Commands) prepareAddOrgSAMLProvider(a *org.Aggregate, writeModel *OrgSA provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ), }, nil @@ -1821,6 +1822,7 @@ func (c *Commands) prepareUpdateOrgSAMLProvider(a *org.Aggregate, writeModel *Or provider.WithSignedRequest, provider.NameIDFormat, provider.TransientMappingAttributeName, + provider.FederatedLogoutEnabled, provider.IDPOptions, ) if err != nil || event == nil { @@ -1866,6 +1868,7 @@ func (c *Commands) prepareRegenerateOrgSAMLProviderCertificate(a *org.Aggregate, writeModel.WithSignedRequest, writeModel.NameIDFormat, writeModel.TransientMappingAttributeName, + writeModel.FederatedLogoutEnabled, writeModel.Options, ) if err != nil || event == nil { diff --git a/internal/command/org_idp_model.go b/internal/command/org_idp_model.go index 3baea11495..fdafb7f087 100644 --- a/internal/command/org_idp_model.go +++ b/internal/command/org_idp_model.go @@ -935,6 +935,7 @@ func (wm *OrgSAMLIDPWriteModel) NewChangedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) (*org.SAMLIDPChangedEvent, error) { changes, err := wm.SAMLIDPWriteModel.NewChanges( @@ -947,6 +948,7 @@ func (wm *OrgSAMLIDPWriteModel) NewChangedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ) if err != nil || len(changes) == 0 { diff --git a/internal/command/org_idp_test.go b/internal/command/org_idp_test.go index 9959ced97d..54f508da30 100644 --- a/internal/command/org_idp_test.go +++ b/internal/command/org_idp_test.go @@ -5519,6 +5519,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, ), ), @@ -5560,6 +5561,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { true, gu.Ptr(domain.SAMLNameIDFormatTransient), "customAttribute", + true, idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5583,6 +5585,7 @@ func TestCommandSide_AddOrgSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5756,6 +5759,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { false, nil, "", + false, idp.Options{}, )), ), @@ -5795,6 +5799,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), @@ -5810,6 +5815,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { idp.ChangeSAMLWithSignedRequest(true), idp.ChangeSAMLNameIDFormat(gu.Ptr(domain.SAMLNameIDFormatTransient)), idp.ChangeSAMLTransientMappingAttributeName("customAttribute"), + idp.ChangeSAMLFederatedLogoutEnabled(true), idp.ChangeSAMLOptions(idp.OptionChanges{ IsCreationAllowed: &t, IsLinkingAllowed: &t, @@ -5835,6 +5841,7 @@ func TestCommandSide_UpdateOrgSAMLIDP(t *testing.T) { WithSignedRequest: true, NameIDFormat: gu.Ptr(domain.SAMLNameIDFormatTransient), TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, IDPOptions: idp.Options{ IsCreationAllowed: true, IsLinkingAllowed: true, @@ -5943,6 +5950,7 @@ func TestCommandSide_RegenerateOrgSAMLProviderCertificate(t *testing.T) { false, gu.Ptr(domain.SAMLNameIDFormatUnspecified), "", + false, idp.Options{}, )), ), diff --git a/internal/domain/federatedlogout/logout.go b/internal/domain/federatedlogout/logout.go new file mode 100644 index 0000000000..ca208a129a --- /dev/null +++ b/internal/domain/federatedlogout/logout.go @@ -0,0 +1,37 @@ +package federatedlogout + +type Index int + +const ( + IndexUnspecified Index = iota + IndexRequestID +) + +type FederatedLogout struct { + InstanceID string + FingerPrintID string + SessionID string + IDPID string + UserID string + PostLogoutRedirectURI string + State State +} + +// Keys implements cache.Entry +func (c *FederatedLogout) Keys(i Index) []string { + if i == IndexRequestID { + return []string{Key(c.InstanceID, c.SessionID)} + } + return nil +} + +func Key(instanceID, sessionID string) string { + return instanceID + "-" + sessionID +} + +type State int + +const ( + StateCreated State = iota + StateRedirected +) diff --git a/internal/query/idp_template.go b/internal/query/idp_template.go index f51e9a11a7..c1c47e73bc 100644 --- a/internal/query/idp_template.go +++ b/internal/query/idp_template.go @@ -165,6 +165,7 @@ type SAMLIDPTemplate struct { WithSignedRequest bool NameIDFormat sql.Null[domain.SAMLNameIDFormat] TransientMappingAttributeName string + FederatedLogoutEnabled bool } var ( @@ -724,6 +725,10 @@ var ( name: projection.SAMLTransientMappingAttributeName, table: samlIdpTemplateTable, } + SAMLFederatedLogoutEnabledCol = Column{ + name: projection.SAMLFederatedLogoutEnabled, + table: samlIdpTemplateTable, + } ) // IDPTemplateByID searches for the requested id with permission check if necessary @@ -948,6 +953,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla SAMLWithSignedRequestCol.identifier(), SAMLNameIDFormatCol.identifier(), SAMLTransientMappingAttributeNameCol.identifier(), + SAMLFederatedLogoutEnabledCol.identifier(), // ldap LDAPIDCol.identifier(), LDAPServersCol.identifier(), @@ -1067,6 +1073,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla samlWithSignedRequest := sql.NullBool{} samlNameIDFormat := sql.Null[domain.SAMLNameIDFormat]{} samlTransientMappingAttributeName := sql.NullString{} + samlFederatedLogoutEnabled := sql.NullBool{} ldapID := sql.NullString{} ldapServers := database.TextArray[string]{} @@ -1184,6 +1191,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla &samlWithSignedRequest, &samlNameIDFormat, &samlTransientMappingAttributeName, + &samlFederatedLogoutEnabled, // ldap &ldapID, &ldapServers, @@ -1323,6 +1331,7 @@ func prepareIDPTemplateByIDQuery() (sq.SelectBuilder, func(*sql.Row) (*IDPTempla WithSignedRequest: samlWithSignedRequest.Bool, NameIDFormat: samlNameIDFormat, TransientMappingAttributeName: samlTransientMappingAttributeName.String, + FederatedLogoutEnabled: samlFederatedLogoutEnabled.Bool, } } if ldapID.Valid { @@ -1456,6 +1465,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate SAMLWithSignedRequestCol.identifier(), SAMLNameIDFormatCol.identifier(), SAMLTransientMappingAttributeNameCol.identifier(), + SAMLFederatedLogoutEnabledCol.identifier(), // ldap LDAPIDCol.identifier(), LDAPServersCol.identifier(), @@ -1580,6 +1590,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate samlWithSignedRequest := sql.NullBool{} samlNameIDFormat := sql.Null[domain.SAMLNameIDFormat]{} samlTransientMappingAttributeName := sql.NullString{} + samlFederatedLogoutEnabled := sql.NullBool{} ldapID := sql.NullString{} ldapServers := database.TextArray[string]{} @@ -1697,6 +1708,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate &samlWithSignedRequest, &samlNameIDFormat, &samlTransientMappingAttributeName, + &samlFederatedLogoutEnabled, // ldap &ldapID, &ldapServers, @@ -1835,6 +1847,7 @@ func prepareIDPTemplatesQuery() (sq.SelectBuilder, func(*sql.Rows) (*IDPTemplate WithSignedRequest: samlWithSignedRequest.Bool, NameIDFormat: samlNameIDFormat, TransientMappingAttributeName: samlTransientMappingAttributeName.String, + FederatedLogoutEnabled: samlFederatedLogoutEnabled.Bool, } } if ldapID.Valid { diff --git a/internal/query/idp_template_test.go b/internal/query/idp_template_test.go index 702e5d0ced..aed243c61e 100644 --- a/internal/query/idp_template_test.go +++ b/internal/query/idp_template_test.go @@ -99,6 +99,7 @@ var ( ` projections.idp_templates6_saml.with_signed_request,` + ` projections.idp_templates6_saml.name_id_format,` + ` projections.idp_templates6_saml.transient_mapping_attribute_name,` + + ` projections.idp_templates6_saml.federated_logout_enabled,` + // ldap ` projections.idp_templates6_ldap2.idp_id,` + ` projections.idp_templates6_ldap2.servers,` + @@ -228,6 +229,7 @@ var ( "with_signed_request", "name_id_format", "transient_mapping_attribute_name", + "federated_logout_enabled", // ldap config "idp_id", "servers", @@ -344,6 +346,7 @@ var ( ` projections.idp_templates6_saml.with_signed_request,` + ` projections.idp_templates6_saml.name_id_format,` + ` projections.idp_templates6_saml.transient_mapping_attribute_name,` + + ` projections.idp_templates6_saml.federated_logout_enabled,` + // ldap ` projections.idp_templates6_ldap2.idp_id,` + ` projections.idp_templates6_ldap2.servers,` + @@ -474,6 +477,7 @@ var ( "with_signed_request", "name_id_format", "transient_mapping_attribute_name", + "federated_logout_enabled", // ldap config "idp_id", "servers", @@ -630,6 +634,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -784,6 +789,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -936,6 +942,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1086,6 +1093,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1235,6 +1243,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1384,6 +1393,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1534,6 +1544,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -1683,6 +1694,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { false, domain.SAMLNameIDFormatTransient, "customAttribute", + true, // ldap config nil, nil, @@ -1742,6 +1754,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { WithSignedRequest: false, NameIDFormat: sql.Null[domain.SAMLNameIDFormat]{V: domain.SAMLNameIDFormatTransient, Valid: true}, TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, }, }, }, @@ -1836,6 +1849,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config "idp-id", database.TextArray[string]{"server"}, @@ -2006,6 +2020,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -2157,6 +2172,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -2336,6 +2352,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config "idp-id", database.TextArray[string]{"server"}, @@ -2515,6 +2532,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -2667,6 +2685,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config "idp-id-ldap", database.TextArray[string]{"server"}, @@ -2784,6 +2803,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { false, domain.SAMLNameIDFormatTransient, "customAttribute", + true, // ldap config nil, nil, @@ -2901,6 +2921,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3018,6 +3039,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3135,6 +3157,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3252,6 +3275,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { nil, nil, nil, + nil, // ldap config nil, nil, @@ -3360,6 +3384,7 @@ func Test_IDPTemplateTemplatesPrepares(t *testing.T) { WithSignedRequest: false, NameIDFormat: sql.Null[domain.SAMLNameIDFormat]{V: domain.SAMLNameIDFormatTransient, Valid: true}, TransientMappingAttributeName: "customAttribute", + FederatedLogoutEnabled: true, }, }, { diff --git a/internal/query/projection/idp_template.go b/internal/query/projection/idp_template.go index 55c74b851c..11eeb8c613 100644 --- a/internal/query/projection/idp_template.go +++ b/internal/query/projection/idp_template.go @@ -173,6 +173,7 @@ const ( SAMLWithSignedRequestCol = "with_signed_request" SAMLNameIDFormatCol = "name_id_format" SAMLTransientMappingAttributeName = "transient_mapping_attribute_name" + SAMLFederatedLogoutEnabled = "federated_logout_enabled" ) type idpTemplateProjection struct{} @@ -377,6 +378,7 @@ func (*idpTemplateProjection) Init() *old_handler.Check { handler.NewColumn(SAMLWithSignedRequestCol, handler.ColumnTypeBool, handler.Nullable()), handler.NewColumn(SAMLNameIDFormatCol, handler.ColumnTypeEnum, handler.Nullable()), handler.NewColumn(SAMLTransientMappingAttributeName, handler.ColumnTypeText, handler.Nullable()), + handler.NewColumn(SAMLFederatedLogoutEnabled, handler.ColumnTypeBool, handler.Default(false)), }, handler.NewPrimaryKey(SAMLInstanceIDCol, SAMLIDCol), IDPTemplateSAMLSuffix, @@ -1990,6 +1992,7 @@ func (p *idpTemplateProjection) reduceSAMLIDPAdded(event eventstore.Event) (*han handler.NewCol(SAMLBindingCol, idpEvent.Binding), handler.NewCol(SAMLWithSignedRequestCol, idpEvent.WithSignedRequest), handler.NewCol(SAMLTransientMappingAttributeName, idpEvent.TransientMappingAttributeName), + handler.NewCol(SAMLFederatedLogoutEnabled, idpEvent.FederatedLogoutEnabled), } if idpEvent.NameIDFormat != nil { columns = append(columns, handler.NewCol(SAMLNameIDFormatCol, *idpEvent.NameIDFormat)) @@ -2525,5 +2528,8 @@ func reduceSAMLIDPChangedColumns(idpEvent idp.SAMLIDPChangedEvent) []handler.Col if idpEvent.TransientMappingAttributeName != nil { SAMLCols = append(SAMLCols, handler.NewCol(SAMLTransientMappingAttributeName, *idpEvent.TransientMappingAttributeName)) } + if idpEvent.FederatedLogoutEnabled != nil { + SAMLCols = append(SAMLCols, handler.NewCol(SAMLFederatedLogoutEnabled, *idpEvent.FederatedLogoutEnabled)) + } return SAMLCols } diff --git a/internal/query/projection/idp_template_test.go b/internal/query/projection/idp_template_test.go index cebf3f8791..6ba6dabd47 100644 --- a/internal/query/projection/idp_template_test.go +++ b/internal/query/projection/idp_template_test.go @@ -2793,7 +2793,8 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "isLinkingAllowed": true, "isAutoCreation": true, "isAutoUpdate": true, - "autoLinkingOption": 1 + "autoLinkingOption": 1, + "federatedLogoutEnabled": true }`), ), instance.SAMLIDPAddedEventMapper), }, @@ -2824,7 +2825,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, federated_logout_enabled, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -2834,6 +2835,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "binding", true, "customAttribute", + true, domain.SAMLNameIDFormatTransient, }, }, @@ -2865,7 +2867,8 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "isLinkingAllowed": true, "isAutoCreation": true, "isAutoUpdate": true, - "autoLinkingOption": 1 + "autoLinkingOption": 1, + "federatedLogoutEnabled": true }`), ), org.SAMLIDPAddedEventMapper), }, @@ -2896,7 +2899,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { }, }, { - expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + expectedStmt: "INSERT INTO projections.idp_templates6_saml (idp_id, instance_id, metadata, key, certificate, binding, with_signed_request, transient_mapping_attribute_name, federated_logout_enabled, name_id_format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", expectedArgs: []interface{}{ "idp-id", "instance-id", @@ -2906,6 +2909,7 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "binding", true, "customAttribute", + true, domain.SAMLNameIDFormatTransient, }, }, @@ -2976,7 +2980,8 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { "isLinkingAllowed": true, "isAutoCreation": true, "isAutoUpdate": true, - "autoLinkingOption": 1 + "autoLinkingOption": 1, + "federatedLogoutEnabled": true }`), ), instance.SAMLIDPChangedEventMapper), }, @@ -3002,13 +3007,14 @@ func TestIDPTemplateProjection_reducesSAML(t *testing.T) { }, }, { - expectedStmt: "UPDATE projections.idp_templates6_saml SET (metadata, key, certificate, binding, with_signed_request) = ($1, $2, $3, $4, $5) WHERE (idp_id = $6) AND (instance_id = $7)", + expectedStmt: "UPDATE projections.idp_templates6_saml SET (metadata, key, certificate, binding, with_signed_request, federated_logout_enabled) = ($1, $2, $3, $4, $5, $6) WHERE (idp_id = $7) AND (instance_id = $8)", expectedArgs: []interface{}{ []byte("metadata"), anyArg{}, anyArg{}, "binding", true, + true, "idp-id", "instance-id", }, diff --git a/internal/repository/idp/saml.go b/internal/repository/idp/saml.go index da6a52b1be..209d8371b3 100644 --- a/internal/repository/idp/saml.go +++ b/internal/repository/idp/saml.go @@ -19,6 +19,7 @@ type SAMLIDPAddedEvent struct { WithSignedRequest bool `json:"withSignedRequest,omitempty"` NameIDFormat *domain.SAMLNameIDFormat `json:"nameIDFormat,omitempty"` TransientMappingAttributeName string `json:"transientMappingAttributeName,omitempty"` + FederatedLogoutEnabled bool `json:"federatedLogoutEnabled,omitempty"` Options } @@ -33,6 +34,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options Options, ) *SAMLIDPAddedEvent { return &SAMLIDPAddedEvent{ @@ -46,6 +48,7 @@ func NewSAMLIDPAddedEvent( WithSignedRequest: withSignedRequest, NameIDFormat: nameIDFormat, TransientMappingAttributeName: transientMappingAttributeName, + FederatedLogoutEnabled: federatedLogoutEnabled, Options: options, } } @@ -83,6 +86,7 @@ type SAMLIDPChangedEvent struct { WithSignedRequest *bool `json:"withSignedRequest,omitempty"` NameIDFormat *domain.SAMLNameIDFormat `json:"nameIDFormat,omitempty"` TransientMappingAttributeName *string `json:"transientMappingAttributeName,omitempty"` + FederatedLogoutEnabled *bool `json:"federatedLogoutEnabled,omitempty"` OptionChanges } @@ -154,6 +158,12 @@ func ChangeSAMLTransientMappingAttributeName(name string) func(*SAMLIDPChangedEv } } +func ChangeSAMLFederatedLogoutEnabled(federatedLogoutEnabled bool) func(*SAMLIDPChangedEvent) { + return func(e *SAMLIDPChangedEvent) { + e.FederatedLogoutEnabled = &federatedLogoutEnabled + } +} + func ChangeSAMLOptions(options OptionChanges) func(*SAMLIDPChangedEvent) { return func(e *SAMLIDPChangedEvent) { e.OptionChanges = options diff --git a/internal/repository/instance/idp.go b/internal/repository/instance/idp.go index 6ab60c0dd5..f0f324cb7d 100644 --- a/internal/repository/instance/idp.go +++ b/internal/repository/instance/idp.go @@ -1024,6 +1024,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) *SAMLIDPAddedEvent { return &SAMLIDPAddedEvent{ @@ -1042,6 +1043,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ), } diff --git a/internal/repository/org/idp.go b/internal/repository/org/idp.go index 0070f71a95..5f061370f1 100644 --- a/internal/repository/org/idp.go +++ b/internal/repository/org/idp.go @@ -1025,6 +1025,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest bool, nameIDFormat *domain.SAMLNameIDFormat, transientMappingAttributeName string, + federatedLogoutEnabled bool, options idp.Options, ) *SAMLIDPAddedEvent { @@ -1044,6 +1045,7 @@ func NewSAMLIDPAddedEvent( withSignedRequest, nameIDFormat, transientMappingAttributeName, + federatedLogoutEnabled, options, ), } diff --git a/internal/user/repository/view/user_session.sql b/internal/user/repository/view/user_session.sql new file mode 100644 index 0000000000..3e2a97206b --- /dev/null +++ b/internal/user/repository/view/user_session.sql @@ -0,0 +1,29 @@ +SELECT s.creation_date, + s.change_date, + s.resource_owner, + s.state, + s.user_agent_id, + s.user_id, + u.username, + l.login_name, + h.display_name, + h.avatar_key, + s.selected_idp_config_id, + s.password_verification, + s.passwordless_verification, + s.external_login_verification, + s.second_factor_verification, + s.second_factor_verification_type, + s.multi_factor_verification, + s.multi_factor_verification_type, + s.sequence, + s.instance_id, + s.id +FROM auth.user_sessions s + LEFT JOIN projections.users14 u ON s.user_id = u.id AND s.instance_id = u.instance_id + LEFT JOIN projections.users14_humans h ON s.user_id = h.user_id AND s.instance_id = h.instance_id + LEFT JOIN projections.login_names3 l ON s.user_id = l.user_id AND s.instance_id = l.instance_id AND l.is_primary = true +WHERE (s.id = $1) + AND (s.instance_id = $2) +LIMIT 1 +; \ No newline at end of file diff --git a/internal/user/repository/view/user_session_view.go b/internal/user/repository/view/user_session_view.go index b3d155f1ec..dec22a181d 100644 --- a/internal/user/repository/view/user_session_view.go +++ b/internal/user/repository/view/user_session_view.go @@ -12,6 +12,9 @@ import ( ) //go:embed user_session_by_id.sql +var userSessionByIDsQuery string + +//go:embed user_session.sql var userSessionByIDQuery string //go:embed user_sessions_by_user_agent.sql @@ -30,7 +33,7 @@ func UserSessionByIDs(ctx context.Context, db *database.DB, agentID, userID, ins userSession, err = scanUserSession(row) return err }, - userSessionByIDQuery, + userSessionByIDsQuery, agentID, userID, instanceID, @@ -38,6 +41,20 @@ func UserSessionByIDs(ctx context.Context, db *database.DB, agentID, userID, ins return userSession, err } +func UserSessionByID(ctx context.Context, db *database.DB, userSessionID, instanceID string) (userSession *model.UserSessionView, err error) { + err = db.QueryRowContext( + ctx, + func(row *sql.Row) error { + userSession, err = scanUserSession(row) + return err + }, + userSessionByIDQuery, + userSessionID, + instanceID, + ) + return userSession, err +} + func UserSessionsByAgentID(ctx context.Context, db *database.DB, agentID, instanceID string) (userSessions []*model.UserSessionView, err error) { err = db.QueryContext( ctx, diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 9033fd8668..1e7f3b7407 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -7035,6 +7035,9 @@ message AddSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 8; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 9; } message AddSAMLProviderResponse { @@ -7069,6 +7072,9 @@ message UpdateSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 9; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 10; } message UpdateSAMLProviderResponse { diff --git a/proto/zitadel/idp.proto b/proto/zitadel/idp.proto index 82e32aa873..de497d8c93 100644 --- a/proto/zitadel/idp.proto +++ b/proto/zitadel/idp.proto @@ -479,6 +479,9 @@ message SAMLConfig { // Optional name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 5; + // Boolean weather federated logout is enabled. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 6; } message AzureADConfig { diff --git a/proto/zitadel/idp/v2/idp.proto b/proto/zitadel/idp/v2/idp.proto index 0c95b742f1..663581a659 100644 --- a/proto/zitadel/idp/v2/idp.proto +++ b/proto/zitadel/idp/v2/idp.proto @@ -303,6 +303,9 @@ message SAMLConfig { // in case the nameid-format returned is // `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 5; + // Boolean weather federated logout is enabled. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 6; } message AzureADConfig { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index c90c44667c..3018ebe600 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -13408,6 +13408,9 @@ message AddSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 8; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 9; } message AddSAMLProviderResponse { @@ -13442,6 +13445,9 @@ message UpdateSAMLProviderRequest { // Optionally specify the name of the attribute, which will be used to map the user // in case the nameid-format returned is `urn:oasis:names:tc:SAML:2.0:nameid-format:transient`. optional string transient_mapping_attribute_name = 9; + // Optionally enable federated logout. If enabled, ZITADEL will send a logout request to the identity provider, + // if the user terminates the session in ZITADEL. Be sure to provide a SLO endpoint as part of the metadata. + optional bool federated_logout_enabled = 10; } message UpdateSAMLProviderResponse { From eb0eed21fa682774e476d0c720c501b37f0f7793 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Mon, 26 May 2025 13:23:38 +0200 Subject: [PATCH 065/123] fix(api): correct mapping of user state queries (#9956) # Which Problems Are Solved the mapping of `ListUsers` was wrong for user states. # How the Problems Are Solved mapping of user state introduced to correctly map it # Additional Changes mapping of user type introduced to prevent same issue # Additional Context Requires backport to 2.x and 3.x Co-authored-by: Livio Spring --- internal/api/grpc/user/query.go | 4 +-- internal/api/grpc/user/v2/query.go | 4 +-- internal/api/grpc/user/v2beta/query.go | 4 +-- .../api/scim/resources/user_query_builder.go | 2 +- internal/query/user.go | 4 +-- pkg/grpc/user/user.go | 36 +++++++++++++++++++ pkg/grpc/user/v2/user.go | 34 ++++++++++++++++++ pkg/grpc/user/v2beta/user.go | 34 ++++++++++++++++++ 8 files changed, 113 insertions(+), 9 deletions(-) diff --git a/internal/api/grpc/user/query.go b/internal/api/grpc/user/query.go index 41cce01a8c..66edbac90e 100644 --- a/internal/api/grpc/user/query.go +++ b/internal/api/grpc/user/query.go @@ -84,11 +84,11 @@ func EmailQueryToQuery(q *user_pb.EmailQuery) (query.SearchQuery, error) { } func StateQueryToQuery(q *user_pb.StateQuery) (query.SearchQuery, error) { - return query.NewUserStateSearchQuery(int32(q.State)) + return query.NewUserStateSearchQuery(q.State.ToDomain()) } func TypeQueryToQuery(q *user_pb.TypeQuery) (query.SearchQuery, error) { - return query.NewUserTypeSearchQuery(int32(q.Type)) + return query.NewUserTypeSearchQuery(q.Type.ToDomain()) } func LoginNameQueryToQuery(q *user_pb.LoginNameQuery) (query.SearchQuery, error) { diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go index 136a4a0932..dc886462be 100644 --- a/internal/api/grpc/user/v2/query.go +++ b/internal/api/grpc/user/v2/query.go @@ -298,11 +298,11 @@ func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { } func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { - return query.NewUserStateSearchQuery(int32(q.State)) + return query.NewUserStateSearchQuery(q.State.ToDomain()) } func typeQueryToQuery(q *user.TypeQuery) (query.SearchQuery, error) { - return query.NewUserTypeSearchQuery(int32(q.Type)) + return query.NewUserTypeSearchQuery(q.Type.ToDomain()) } func loginNameQueryToQuery(q *user.LoginNameQuery) (query.SearchQuery, error) { diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index e3602abc33..46b009a72e 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -292,11 +292,11 @@ func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { } func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { - return query.NewUserStateSearchQuery(int32(q.State)) + return query.NewUserStateSearchQuery(q.State.ToDomain()) } func typeQueryToQuery(q *user.TypeQuery) (query.SearchQuery, error) { - return query.NewUserTypeSearchQuery(int32(q.Type)) + return query.NewUserTypeSearchQuery(q.Type.ToDomain()) } func loginNameQueryToQuery(q *user.LoginNameQuery) (query.SearchQuery, error) { diff --git a/internal/api/scim/resources/user_query_builder.go b/internal/api/scim/resources/user_query_builder.go index b86b171fb5..7a06e4b3fd 100644 --- a/internal/api/scim/resources/user_query_builder.go +++ b/internal/api/scim/resources/user_query_builder.go @@ -70,7 +70,7 @@ func (h *UsersHandler) buildListQuery(ctx context.Context, request *ListRequest) } // the zitadel scim implementation only supports humans for now - userTypeQuery, err := query.NewUserTypeSearchQuery(int32(domain.UserTypeHuman)) + userTypeQuery, err := query.NewUserTypeSearchQuery(domain.UserTypeHuman) if err != nil { return nil, err } diff --git a/internal/query/user.go b/internal/query/user.go index a97e3bbd14..3d47847cac 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -776,11 +776,11 @@ func NewUserVerifiedPhoneSearchQuery(value string, comparison TextComparison) (S return NewTextQuery(NotifyVerifiedPhoneCol, value, comparison) } -func NewUserStateSearchQuery(value int32) (SearchQuery, error) { +func NewUserStateSearchQuery(value domain.UserState) (SearchQuery, error) { return NewNumberQuery(UserStateCol, value, NumberEquals) } -func NewUserTypeSearchQuery(value int32) (SearchQuery, error) { +func NewUserTypeSearchQuery(value domain.UserType) (SearchQuery, error) { return NewNumberQuery(UserTypeCol, value, NumberEquals) } diff --git a/pkg/grpc/user/user.go b/pkg/grpc/user/user.go index 450370e704..a86c957fd8 100644 --- a/pkg/grpc/user/user.go +++ b/pkg/grpc/user/user.go @@ -1,5 +1,7 @@ package user +import "github.com/zitadel/zitadel/internal/domain" + type SearchQuery_ResourceOwner struct { ResourceOwner *ResourceOwnerQuery } @@ -13,3 +15,37 @@ type ResourceOwnerQuery struct { type UserType = isUser_Type type MembershipType = isMembership_Type + +func (s UserState) ToDomain() domain.UserState { + switch s { + case UserState_USER_STATE_UNSPECIFIED: + return domain.UserStateUnspecified + case UserState_USER_STATE_ACTIVE: + return domain.UserStateActive + case UserState_USER_STATE_INACTIVE: + return domain.UserStateInactive + case UserState_USER_STATE_DELETED: + return domain.UserStateDeleted + case UserState_USER_STATE_LOCKED: + return domain.UserStateLocked + case UserState_USER_STATE_SUSPEND: + return domain.UserStateSuspend + case UserState_USER_STATE_INITIAL: + return domain.UserStateInitial + default: + return domain.UserStateUnspecified + } +} + +func (t Type) ToDomain() domain.UserType { + switch t { + case Type_TYPE_UNSPECIFIED: + return domain.UserTypeUnspecified + case Type_TYPE_HUMAN: + return domain.UserTypeHuman + case Type_TYPE_MACHINE: + return domain.UserTypeMachine + default: + return domain.UserTypeUnspecified + } +} diff --git a/pkg/grpc/user/v2/user.go b/pkg/grpc/user/v2/user.go index ec9245c8eb..20c3c6fe9b 100644 --- a/pkg/grpc/user/v2/user.go +++ b/pkg/grpc/user/v2/user.go @@ -1,3 +1,37 @@ package user +import "github.com/zitadel/zitadel/internal/domain" + type UserType = isUser_Type + +func (s UserState) ToDomain() domain.UserState { + switch s { + case UserState_USER_STATE_UNSPECIFIED: + return domain.UserStateUnspecified + case UserState_USER_STATE_ACTIVE: + return domain.UserStateActive + case UserState_USER_STATE_INACTIVE: + return domain.UserStateInactive + case UserState_USER_STATE_DELETED: + return domain.UserStateDeleted + case UserState_USER_STATE_LOCKED: + return domain.UserStateLocked + case UserState_USER_STATE_INITIAL: + return domain.UserStateInitial + default: + return domain.UserStateUnspecified + } +} + +func (t Type) ToDomain() domain.UserType { + switch t { + case Type_TYPE_UNSPECIFIED: + return domain.UserTypeUnspecified + case Type_TYPE_HUMAN: + return domain.UserTypeHuman + case Type_TYPE_MACHINE: + return domain.UserTypeMachine + default: + return domain.UserTypeUnspecified + } +} diff --git a/pkg/grpc/user/v2beta/user.go b/pkg/grpc/user/v2beta/user.go index ec9245c8eb..20c3c6fe9b 100644 --- a/pkg/grpc/user/v2beta/user.go +++ b/pkg/grpc/user/v2beta/user.go @@ -1,3 +1,37 @@ package user +import "github.com/zitadel/zitadel/internal/domain" + type UserType = isUser_Type + +func (s UserState) ToDomain() domain.UserState { + switch s { + case UserState_USER_STATE_UNSPECIFIED: + return domain.UserStateUnspecified + case UserState_USER_STATE_ACTIVE: + return domain.UserStateActive + case UserState_USER_STATE_INACTIVE: + return domain.UserStateInactive + case UserState_USER_STATE_DELETED: + return domain.UserStateDeleted + case UserState_USER_STATE_LOCKED: + return domain.UserStateLocked + case UserState_USER_STATE_INITIAL: + return domain.UserStateInitial + default: + return domain.UserStateUnspecified + } +} + +func (t Type) ToDomain() domain.UserType { + switch t { + case Type_TYPE_UNSPECIFIED: + return domain.UserTypeUnspecified + case Type_TYPE_HUMAN: + return domain.UserTypeHuman + case Type_TYPE_MACHINE: + return domain.UserTypeMachine + default: + return domain.UserTypeUnspecified + } +} From 833f6279e11c43652a579f2211311e2fc6c0e2a7 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 26 May 2025 13:59:20 +0200 Subject: [PATCH 066/123] fix: allow invite codes for users with verified mails (#9962) # Which Problems Are Solved Users who started the invitation code verification, but haven't set up any authentication method, need to be able to do so. This might require a new invitation code, which was currently not possible since creation was prevented for users with verified emails. # How the Problems Are Solved - Allow creation of invitation emails for users with verified emails. - Merged the creation and resend into a single method, defaulting the urlTemplate, applicatioName and authRequestID from the previous code (if one exists). On the user service API, the `ResendInviteCode` endpoint has been deprecated in favor of the `CreateInviteCode` # Additional Changes None # Additional Context - Noticed while investigating something internally. - requires backport to 2.x and 3.x --- .../user/v2/integration_test/user_test.go | 27 ++++++ internal/command/user_v2_invite.go | 83 ++++++++----------- internal/command/user_v2_invite_model.go | 2 +- internal/command/user_v2_invite_test.go | 75 +---------------- proto/zitadel/user/v2/user.proto | 4 +- proto/zitadel/user/v2/user_service.proto | 5 ++ 6 files changed, 71 insertions(+), 125 deletions(-) diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 832268dc8c..7f211afd6f 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -3190,6 +3190,33 @@ func TestServer_CreateInviteCode(t *testing.T) { }, }, }, + { + name: "recreate", + args: args{ + ctx: CTX, + req: &user.CreateInviteCodeRequest{}, + prepare: func(request *user.CreateInviteCodeRequest) error { + resp := Instance.CreateHumanUser(CTX) + request.UserId = resp.GetUserId() + _, err := Instance.Client.UserV2.CreateInviteCode(CTX, &user.CreateInviteCodeRequest{ + UserId: resp.GetUserId(), + Verification: &user.CreateInviteCodeRequest_SendCode{ + SendCode: &user.SendInviteCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + ApplicationName: gu.Ptr("TestApp"), + }, + }, + }) + return err + }, + }, + want: &user.CreateInviteCodeResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, { name: "create, return code, ok", args: args{ diff --git a/internal/command/user_v2_invite.go b/internal/command/user_v2_invite.go index 7760107146..430ba8c7d1 100644 --- a/internal/command/user_v2_invite.go +++ b/internal/command/user_v2_invite.go @@ -19,14 +19,34 @@ type CreateUserInvite struct { URLTemplate string ReturnCode bool ApplicationName string + AuthRequestID string } func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvite) (details *domain.ObjectDetails, returnCode *string, err error) { + return c.sendInviteCode(ctx, invite, "", false) +} + +// ResendInviteCode resends the invite mail with a new code and an optional authRequestID. +// It will reuse the applicationName from the previous code. +func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { + details, _, err := c.sendInviteCode( + ctx, + &CreateUserInvite{ + UserID: userID, + AuthRequestID: authRequestID, + }, + resourceOwner, + true, + ) + return details, err +} + +func (c *Commands) sendInviteCode(ctx context.Context, invite *CreateUserInvite, resourceOwner string, requireExisting bool) (details *domain.ObjectDetails, returnCode *string, err error) { invite.UserID = strings.TrimSpace(invite.UserID) if invite.UserID == "" { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing") } - wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, "") + wm, err := c.userInviteCodeWriteModel(ctx, invite.UserID, resourceOwner) if err != nil { return nil, nil, err } @@ -39,10 +59,22 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit if !wm.CreationAllowed() { return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised") } + if requireExisting && wm.InviteCode == nil || wm.CodeReturned { + return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") + } code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint if err != nil { return nil, nil, err } + if invite.URLTemplate == "" { + invite.URLTemplate = wm.URLTemplate + } + if invite.ApplicationName == "" { + invite.ApplicationName = wm.ApplicationName + } + if invite.AuthRequestID == "" { + invite.AuthRequestID = wm.AuthRequestID + } err = c.pushAppendAndReduce(ctx, wm, user.NewHumanInviteCodeAddedEvent( ctx, UserAggregateFromWriteModelCtx(ctx, &wm.WriteModel), @@ -51,7 +83,7 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit invite.URLTemplate, invite.ReturnCode, invite.ApplicationName, - "", + invite.AuthRequestID, )) if err != nil { return nil, nil, err @@ -62,53 +94,6 @@ func (c *Commands) CreateInviteCode(ctx context.Context, invite *CreateUserInvit return writeModelToObjectDetails(&wm.WriteModel), returnCode, nil } -// ResendInviteCode resends the invite mail with a new code and an optional authRequestID. -// It will reuse the applicationName from the previous code. -func (c *Commands) ResendInviteCode(ctx context.Context, userID, resourceOwner, authRequestID string) (objectDetails *domain.ObjectDetails, err error) { - if userID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing") - } - - existingCode, err := c.userInviteCodeWriteModel(ctx, userID, resourceOwner) - if err != nil { - return nil, err - } - if !existingCode.UserState.Exists() { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound") - } - if err := c.checkPermissionUpdateUser(ctx, existingCode.ResourceOwner, userID); err != nil { - return nil, err - } - if !existingCode.CreationAllowed() { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Gg42s", "Errors.User.AlreadyInitialised") - } - if existingCode.InviteCode == nil || existingCode.CodeReturned { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wr3gq", "Errors.User.Code.NotFound") - } - code, err := c.newUserInviteCode(ctx, c.eventstore.Filter, c.userEncryption) //nolint - if err != nil { - return nil, err - } - if authRequestID == "" { - authRequestID = existingCode.AuthRequestID - } - err = c.pushAppendAndReduce(ctx, existingCode, - user.NewHumanInviteCodeAddedEvent( - ctx, - UserAggregateFromWriteModelCtx(ctx, &existingCode.WriteModel), - code.Crypted, - code.Expiry, - existingCode.URLTemplate, - false, - existingCode.ApplicationName, - authRequestID, - )) - if err != nil { - return nil, err - } - return writeModelToObjectDetails(&existingCode.WriteModel), nil -} - func (c *Commands) InviteCodeSent(ctx context.Context, userID, orgID string) (err error) { if userID == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-Sgf31", "Errors.User.UserIDMissing") diff --git a/internal/command/user_v2_invite_model.go b/internal/command/user_v2_invite_model.go index 23f6322a19..6b2ab62e0d 100644 --- a/internal/command/user_v2_invite_model.go +++ b/internal/command/user_v2_invite_model.go @@ -28,7 +28,7 @@ type UserV2InviteWriteModel struct { } func (wm *UserV2InviteWriteModel) CreationAllowed() bool { - return !wm.EmailVerified && !wm.AuthMethodSet + return !wm.AuthMethodSet } func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel { diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go index 817987e7e4..04c00d876e 100644 --- a/internal/command/user_v2_invite_test.go +++ b/internal/command/user_v2_invite_test.go @@ -11,7 +11,6 @@ import ( "go.uber.org/mock/gomock" "golang.org/x/text/language" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -316,7 +315,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { userID: "", }, want{ - err: zerrors.ThrowInvalidArgument(nil, "COMMAND-2n8vs", "Errors.User.UserIDMissing"), + err: zerrors.ThrowInvalidArgument(nil, "COMMAND-4jio3", "Errors.User.UserIDMissing"), }, }, { @@ -362,7 +361,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { userID: "unknown", }, want{ - err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-H3b2a", "Errors.User.NotFound"), + err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-Wgvn4", "Errors.User.NotFound"), }, }, { @@ -580,76 +579,6 @@ func TestCommands_ResendInviteCode(t *testing.T) { }, }, }, - { - "resend with own user ok", - fields{ - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - user.NewHumanAddedEvent(context.Background(), - &user.NewAggregate("userID", "org1").Aggregate, - "username", "firstName", - "lastName", - "nickName", - "displayName", - language.Afrikaans, - domain.GenderUnspecified, - "email", - false, - ), - ), - eventFromEventPusher( - user.NewHumanInviteCodeAddedEvent(context.Background(), - &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("code"), - }, - time.Hour, - "", - false, - "", - "authRequestID", - ), - ), - ), - expectPush( - eventFromEventPusher( - user.NewHumanInviteCodeAddedEvent(authz.NewMockContext("instanceID", "org1", "userID"), - &user.NewAggregate("userID", "org1").Aggregate, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("code"), - }, - time.Hour, - "", - false, - "", - "authRequestID2", - ), - ), - ), - ), - checkPermission: newMockPermissionCheckNotAllowed(), // user does not have permission, is allowed in the own context - newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour), - defaultSecretGenerators: &SecretGenerators{}, - }, - args{ - ctx: authz.NewMockContext("instanceID", "org1", "userID"), - userID: "userID", - authRequestID: "authRequestID2", - }, - want{ - details: &domain.ObjectDetails{ - ResourceOwner: "org1", - ID: "userID", - }, - }, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proto/zitadel/user/v2/user.proto b/proto/zitadel/user/v2/user.proto index e2a140ea27..9ea2b8906e 100644 --- a/proto/zitadel/user/v2/user.proto +++ b/proto/zitadel/user/v2/user.proto @@ -334,7 +334,7 @@ message AuthFactorU2F { message SendInviteCode { // Optionally set a url_template, which will be used in the invite mail sent by ZITADEL to guide the user to your invitation page. - // If no template is set, the default ZITADEL url will be used. + // If no template is set and no previous code was created, the default ZITADEL url will be used. // // The following placeholders can be used: UserID, OrgID, Code optional string url_template = 1 [ @@ -346,7 +346,7 @@ message SendInviteCode { } ]; // Optionally set an application name, which will be used in the invite mail sent by ZITADEL. - // If no application name is set, ZITADEL will be used as default. + // If no application name is set and no previous code was created, ZITADEL will be used as default. optional string application_name = 2 [ (validate.rules).string = {min_len: 1, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 15bc2d7775..44d25c07b3 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -1135,6 +1135,8 @@ service UserService { // Create an invite code for a user // // Create an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. + // If an invite code has been created previously, it's url template and application name will be used as defaults for the new code. + // The new code will overwrite the previous one and make it invalid. rpc CreateInviteCode (CreateInviteCodeRequest) returns (CreateInviteCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/invite_code" @@ -1158,6 +1160,8 @@ service UserService { // Resend an invite code for a user // + // Deprecated: Use [CreateInviteCode](apis/resources/user_service_v2/user-service-create-invite-code.api.mdx) instead. + // // Resend an invite code for a user to initialize their first authentication method (password, passkeys, IdP) depending on the organization's available methods. // A resend is only possible if a code has been created previously and sent to the user. If there is no code or it was directly returned, an error will be returned. rpc ResendInviteCode (ResendInviteCodeRequest) returns (ResendInviteCodeResponse) { @@ -1172,6 +1176,7 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { From 4d66a786c88ba624bb8ca7bd67a128b920e0cb7f Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 27 May 2025 16:26:46 +0200 Subject: [PATCH 067/123] feat: JWT IdP intent (#9966) # Which Problems Are Solved The login v1 allowed to use JWTs as IdP using the JWT IDP. The login V2 uses idp intents for such cases, which were not yet able to handle JWT IdPs. # How the Problems Are Solved - Added handling of JWT IdPs in `StartIdPIntent` and `RetrieveIdPIntent` - The redirect returned by the start, uses the existing `authRequestID` and `userAgentID` parameter names for compatibility reasons. - Added `/idps/jwt` endpoint to handle the proxied (callback) endpoint , which extracts and validates the JWT against the configured endpoint. # Additional Changes None # Additional Context - closes #9758 --- .../integrate/identity-providers/jwt_idp.md | 2 +- docs/static/img/guides/jwt_idp.png | Bin 275050 -> 38738 bytes .../user/v2/integration_test/user_test.go | 112 ++++++++++++++++++ internal/api/grpc/user/v2/intent.go | 2 +- internal/api/idp/idp.go | 86 ++++++++++++++ internal/idp/providers/jwt/jwt.go | 22 ++-- internal/idp/providers/jwt/jwt_test.go | 28 ++++- internal/idp/providers/jwt/session.go | 13 ++ internal/integration/client.go | 18 +++ internal/integration/sink/server.go | 52 ++++++++ 10 files changed, 319 insertions(+), 16 deletions(-) diff --git a/docs/docs/guides/integrate/identity-providers/jwt_idp.md b/docs/docs/guides/integrate/identity-providers/jwt_idp.md index 2a0e8b8e7a..52324ad5c2 100644 --- a/docs/docs/guides/integrate/identity-providers/jwt_idp.md +++ b/docs/docs/guides/integrate/identity-providers/jwt_idp.md @@ -49,7 +49,7 @@ The **JWT IdP Configuration** might then be: Therefore, if the user is redirected from ZITADEL to the JWT Endpoint on the WAF (`https://apps.test.com/existing/auth-new`), the session cookies previously issued by the WAF, will be sent along by the browser due to the path being on the same domain as the exiting application. -The WAF will reuse the session and send the JWT in the HTTP header `x-custom-tkn` to its upstream, the ZITADEL JWT Endpoint (`https://accounts.test.com/ui/login/login/jwt/authorize`). +The WAF will reuse the session and send the JWT in the HTTP header `x-custom-tkn` to its upstream, the ZITADEL JWT Endpoint (`https://accounts.test.com/ipds/jwt`). For the signature validation, ZITADEL must be able to connect to Keys Endpoint (`https://issuer.test.internal/keys`) and it will check if the token was signed (claim `iss`) by the defined Issuer (`https://issuer.test.internal`). diff --git a/docs/static/img/guides/jwt_idp.png b/docs/static/img/guides/jwt_idp.png index 73d5353521a9eb844c801ad9fe3bfc6291181dc9..218996aef756b0033575c54ff604034108a7f46a 100644 GIT binary patch literal 38738 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 literal 275050 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

xuQ1w*QK&q!^g}p!>(K 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

<<~ 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 diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 7f211afd6f..4cf4ab21f8 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -1875,6 +1875,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { samlIdpID := Instance.AddSAMLProvider(IamCTX) samlRedirectIdpID := Instance.AddSAMLRedirectProvider(IamCTX, "") samlPostIdpID := Instance.AddSAMLPostProvider(IamCTX) + jwtIdPID := Instance.AddJWTProvider(IamCTX) type args struct { ctx context.Context req *user.StartIdentityProviderIntentRequest @@ -2097,6 +2098,30 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { }, wantErr: false, }, + { + name: "next step jwt idp", + args: args{ + CTX, + &user.StartIdentityProviderIntentRequest{ + IdpId: jwtIdPID, + Content: &user.StartIdentityProviderIntentRequest_Urls{ + Urls: &user.RedirectURLs{ + SuccessUrl: "https://example.com/success", + FailureUrl: "https://example.com/failure", + }, + }, + }, + }, + want: want{ + details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: Instance.ID(), + }, + url: "https://example.com/jwt", + parametersExisting: []string{"authRequestID", "userAgentID"}, + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2134,6 +2159,7 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { oidcIdpID := Instance.AddGenericOIDCProvider(IamCTX, gofakeit.AppName()).GetId() samlIdpID := Instance.AddSAMLPostProvider(IamCTX) ldapIdpID := Instance.AddLDAPProvider(IamCTX) + jwtIdPID := Instance.AddJWTProvider(IamCTX) authURL, err := url.Parse(Instance.CreateIntent(CTX, oauthIdpID).GetAuthUrl()) require.NoError(t, err) intentID := authURL.Query().Get("state") @@ -2168,6 +2194,10 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { require.NoError(t, err) samlSuccessfulWithUserID, samlWithUserToken, samlWithUserChangeDate, samlWithUserSequence, err := sink.SuccessfulSAMLIntent(Instance.ID(), samlIdpID, "id", "user", expiry) require.NoError(t, err) + jwtSuccessfulID, jwtToken, jwtChangeDate, jwtSequence, err := sink.SuccessfulJWTIntent(Instance.ID(), jwtIdPID, "id", "", expiry) + require.NoError(t, err) + jwtSuccessfulWithUserID, jwtWithUserToken, jwtWithUserChangeDate, jwtWithUserSequence, err := sink.SuccessfulJWTIntent(Instance.ID(), jwtIdPID, "id", "user", expiry) + require.NoError(t, err) type args struct { ctx context.Context req *user.RetrieveIdentityProviderIntentRequest @@ -2591,6 +2621,88 @@ func TestServer_RetrieveIdentityProviderIntent(t *testing.T) { }, wantErr: false, }, + { + name: "retrieve successful jwt intent", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: jwtSuccessfulID, + IdpIntentToken: jwtToken, + }, + }, + want: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(jwtChangeDate), + ResourceOwner: Instance.ID(), + Sequence: jwtSequence, + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + IdToken: gu.Ptr("idToken"), + }, + }, + IdpId: jwtIdPID, + UserId: "id", + UserName: "", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "sub": "id", + }) + require.NoError(t, err) + return s + }(), + }, + AddHumanUser: &user.AddHumanUserRequest{ + Profile: &user.SetHumanProfile{ + PreferredLanguage: gu.Ptr("und"), + }, + IdpLinks: []*user.IDPLink{ + {IdpId: jwtIdPID, UserId: "id"}, + }, + Email: &user.SetHumanEmail{ + Verification: &user.SetHumanEmail_SendCode{SendCode: &user.SendEmailVerificationCode{}}, + }, + }, + }, + wantErr: false, + }, + { + name: "retrieve successful jwt intent with linked user", + args: args{ + CTX, + &user.RetrieveIdentityProviderIntentRequest{ + IdpIntentId: jwtSuccessfulWithUserID, + IdpIntentToken: jwtWithUserToken, + }, + }, + want: &user.RetrieveIdentityProviderIntentResponse{ + Details: &object.Details{ + ChangeDate: timestamppb.New(jwtWithUserChangeDate), + ResourceOwner: Instance.ID(), + Sequence: jwtWithUserSequence, + }, + IdpInformation: &user.IDPInformation{ + Access: &user.IDPInformation_Oauth{ + Oauth: &user.IDPOAuthAccessInformation{ + IdToken: gu.Ptr("idToken"), + }, + }, + IdpId: jwtIdPID, + UserId: "id", + UserName: "", + RawInformation: func() *structpb.Struct { + s, err := structpb.NewStruct(map[string]interface{}{ + "sub": "id", + }) + require.NoError(t, err) + return s + }(), + }, + UserId: "user", + }, + wantErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index afb34deb83..5514b6ef03 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -173,7 +173,7 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R case *oidc.Provider: idpUser, err = unmarshalIdpUser(intent.IDPUser, oidc.InitUser()) case *jwt.Provider: - idpUser, err = unmarshalIdpUser(intent.IDPUser, &jwt.User{}) + idpUser, err = unmarshalIdpUser(intent.IDPUser, jwt.InitUser()) case *azuread.Provider: idpUser, err = unmarshalRawIdpUser(intent.IDPUser, p.User()) case *github.Provider: diff --git a/internal/api/idp/idp.go b/internal/api/idp/idp.go index f688ba2352..8b1c24134a 100644 --- a/internal/api/idp/idp.go +++ b/internal/api/idp/idp.go @@ -3,6 +3,7 @@ package idp import ( "bytes" "context" + "encoding/base64" "encoding/xml" "errors" "fmt" @@ -48,6 +49,7 @@ const ( acsPath = idpPrefix + "/saml/acs" certificatePath = idpPrefix + "/saml/certificate" sloPath = idpPrefix + "/saml/slo" + jwtPath = "/jwt" paramIntentID = "id" paramToken = "token" @@ -129,6 +131,7 @@ func NewHandler( router.HandleFunc(certificatePath, h.handleCertificate) router.HandleFunc(acsPath, h.handleACS) router.HandleFunc(sloPath, h.handleSLO) + router.HandleFunc(jwtPath, h.handleJWT) return router } @@ -307,6 +310,89 @@ func (h *Handler) handleACS(w http.ResponseWriter, r *http.Request) { redirectToSuccessURL(w, r, intent, token, userID) } +func (h *Handler) handleJWT(w http.ResponseWriter, r *http.Request) { + intentID, err := h.intentIDFromJWTRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + intent, err := h.commands.GetActiveIntent(r.Context(), intentID) + if err != nil { + if zerrors.IsNotFound(err) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + redirectToFailureURLErr(w, r, intent, err) + return + } + idpConfig, err := h.getProvider(r.Context(), intent.IDPID) + if err != nil { + cmdErr := h.commands.FailIDPIntent(r.Context(), intent, err.Error()) + logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") + redirectToFailureURLErr(w, r, intent, err) + return + } + jwtIDP, ok := idpConfig.(*jwt.Provider) + if !ok { + err := zerrors.ThrowInvalidArgument(nil, "IDP-JK23ed", "Errors.ExternalIDP.IDPTypeNotImplemented") + cmdErr := h.commands.FailIDPIntent(r.Context(), intent, err.Error()) + logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") + redirectToFailureURLErr(w, r, intent, err) + return + } + h.handleJWTExtraction(w, r, intent, jwtIDP) +} + +func (h *Handler) intentIDFromJWTRequest(r *http.Request) (string, error) { + // for compatibility of the old JWT provider we use the auth request id parameter to pass the intent id + intentID := r.FormValue(jwt.QueryAuthRequestID) + // for compatibility of the old JWT provider we use the user agent id parameter to pass the encrypted intent id + encryptedIntentID := r.FormValue(jwt.QueryUserAgentID) + if err := h.checkIntentID(intentID, encryptedIntentID); err != nil { + return "", err + } + return intentID, nil +} + +func (h *Handler) checkIntentID(intentID, encryptedIntentID string) error { + if intentID == "" || encryptedIntentID == "" { + return zerrors.ThrowInvalidArgument(nil, "LOGIN-adfzz", "Errors.AuthRequest.MissingParameters") + } + id, err := base64.RawURLEncoding.DecodeString(encryptedIntentID) + if err != nil { + return err + } + decryptedIntentID, err := h.encryptionAlgorithm.DecryptString(id, h.encryptionAlgorithm.EncryptionKeyID()) + if err != nil { + return err + } + if intentID != decryptedIntentID { + return zerrors.ThrowInvalidArgument(nil, "LOGIN-adfzz", "Errors.AuthRequest.MissingParameters") + } + return nil +} + +func (h *Handler) handleJWTExtraction(w http.ResponseWriter, r *http.Request, intent *command.IDPIntentWriteModel, identityProvider *jwt.Provider) { + session := jwt.NewSessionFromRequest(identityProvider, r) + user, err := session.FetchUser(r.Context()) + if err != nil { + cmdErr := h.commands.FailIDPIntent(r.Context(), intent, err.Error()) + logging.WithFields("intent", intent.AggregateID).OnError(cmdErr).Error("failed to push failed event on idp intent") + redirectToFailureURLErr(w, r, intent, err) + return + } + + userID, err := h.checkExternalUser(r.Context(), intent.IDPID, user.GetID()) + logging.WithFields("intent", intent.AggregateID).OnError(err).Error("could not check if idp user already exists") + + token, err := h.commands.SucceedIDPIntent(r.Context(), intent, user, session, userID) + if err != nil { + redirectToFailureURLErr(w, r, intent, err) + return + } + redirectToSuccessURL(w, r, intent, token, userID) +} + func (h *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { ctx := r.Context() data, err := h.parseCallbackRequest(r) diff --git a/internal/idp/providers/jwt/jwt.go b/internal/idp/providers/jwt/jwt.go index 99347f31a3..d972102b01 100644 --- a/internal/idp/providers/jwt/jwt.go +++ b/internal/idp/providers/jwt/jwt.go @@ -11,14 +11,14 @@ import ( ) const ( - queryAuthRequestID = "authRequestID" - queryUserAgentID = "userAgentID" + QueryAuthRequestID = "authRequestID" + QueryUserAgentID = "userAgentID" ) var _ idp.Provider = (*Provider)(nil) var ( - ErrMissingUserAgentID = errors.New("userAgentID missing") + ErrMissingState = errors.New("state missing") ) // Provider is the [idp.Provider] implementation for a JWT provider @@ -92,32 +92,32 @@ func (p *Provider) Name() string { // It will create a [Session] with an AuthURL, pointing to the jwtEndpoint // with the authRequest and encrypted userAgent ids. func (p *Provider) BeginAuth(ctx context.Context, state string, params ...idp.Parameter) (idp.Session, error) { - userAgentID, err := userAgentIDFromParams(params...) - if err != nil { - return nil, err + if state == "" { + return nil, ErrMissingState } + userAgentID := userAgentIDFromParams(state, params...) redirect, err := url.Parse(p.jwtEndpoint) if err != nil { return nil, err } q := redirect.Query() - q.Set(queryAuthRequestID, state) + q.Set(QueryAuthRequestID, state) nonce, err := p.encryptionAlg.Encrypt([]byte(userAgentID)) if err != nil { return nil, err } - q.Set(queryUserAgentID, base64.RawURLEncoding.EncodeToString(nonce)) + q.Set(QueryUserAgentID, base64.RawURLEncoding.EncodeToString(nonce)) redirect.RawQuery = q.Encode() return &Session{AuthURL: redirect.String()}, nil } -func userAgentIDFromParams(params ...idp.Parameter) (string, error) { +func userAgentIDFromParams(state string, params ...idp.Parameter) string { for _, param := range params { if id, ok := param.(idp.UserAgentID); ok { - return string(id), nil + return string(id) } } - return "", ErrMissingUserAgentID + return state } // IsLinkingAllowed implements the [idp.Provider] interface. diff --git a/internal/idp/providers/jwt/jwt_test.go b/internal/idp/providers/jwt/jwt_test.go index 59e32b4690..5756c58e07 100644 --- a/internal/idp/providers/jwt/jwt_test.go +++ b/internal/idp/providers/jwt/jwt_test.go @@ -23,6 +23,7 @@ func TestProvider_BeginAuth(t *testing.T) { encryptionAlg func(t *testing.T) crypto.EncryptionAlgorithm } type args struct { + state string params []idp.Parameter } type want struct { @@ -36,7 +37,7 @@ func TestProvider_BeginAuth(t *testing.T) { want want }{ { - name: "missing userAgentID error", + name: "missing state, error", fields: fields{ issuer: "https://jwt.com", jwtEndpoint: "https://auth.com/jwt", @@ -47,14 +48,34 @@ func TestProvider_BeginAuth(t *testing.T) { }, }, args: args{ + state: "", params: nil, }, want: want{ err: func(err error) bool { - return errors.Is(err, ErrMissingUserAgentID) + return errors.Is(err, ErrMissingState) }, }, }, + { + name: "missing userAgentID, fallback to state", + fields: fields{ + issuer: "https://jwt.com", + jwtEndpoint: "https://auth.com/jwt", + keysEndpoint: "https://jwt.com/keys", + headerName: "jwt-header", + encryptionAlg: func(t *testing.T) crypto.EncryptionAlgorithm { + return crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + }, + }, + args: args{ + state: "testState", + params: nil, + }, + want: want{ + session: &Session{AuthURL: "https://auth.com/jwt?authRequestID=testState&userAgentID=dGVzdFN0YXRl"}, + }, + }, { name: "successful auth", fields: fields{ @@ -67,6 +88,7 @@ func TestProvider_BeginAuth(t *testing.T) { }, }, args: args{ + state: "testState", params: []idp.Parameter{ idp.UserAgentID("agent"), }, @@ -91,7 +113,7 @@ func TestProvider_BeginAuth(t *testing.T) { require.NoError(t, err) ctx := context.Background() - session, err := provider.BeginAuth(ctx, "testState", tt.args.params...) + session, err := provider.BeginAuth(ctx, tt.args.state, tt.args.params...) if tt.want.err != nil && !tt.want.err(err) { a.Fail("invalid error", err) } diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go index 5138812f3c..85b164a9c5 100644 --- a/internal/idp/providers/jwt/session.go +++ b/internal/idp/providers/jwt/session.go @@ -5,11 +5,13 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" + "golang.org/x/oauth2" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/domain" @@ -34,6 +36,11 @@ func NewSession(provider *Provider, tokens *oidc.Tokens[*oidc.IDTokenClaims]) *S return &Session{Provider: provider, Tokens: tokens} } +func NewSessionFromRequest(provider *Provider, r *http.Request) *Session { + token := strings.TrimPrefix(r.Header.Get(provider.headerName), oidc.PrefixBearer) + return NewSession(provider, &oidc.Tokens[*oidc.IDTokenClaims]{IDToken: token, Token: &oauth2.Token{}}) +} + // GetAuth implements the [idp.Session] interface. func (s *Session) GetAuth(ctx context.Context) (string, bool) { return idp.Redirect(s.AuthURL) @@ -99,6 +106,12 @@ func (s *Session) validateToken(ctx context.Context, token string) (*oidc.IDToke return claims, nil } +func InitUser() *User { + return &User{ + IDTokenClaims: &oidc.IDTokenClaims{}, + } +} + type User struct { *oidc.IDTokenClaims } diff --git a/internal/integration/client.go b/internal/integration/client.go index 3efd682ee1..320809a7e8 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -684,6 +684,24 @@ func (i *Instance) AddLDAPProvider(ctx context.Context) string { return resp.GetId() } +func (i *Instance) AddJWTProvider(ctx context.Context) string { + resp, err := i.Client.Admin.AddJWTProvider(ctx, &admin.AddJWTProviderRequest{ + Name: "jwt-idp", + Issuer: "https://example.com", + JwtEndpoint: "https://example.com/jwt", + KeysEndpoint: "https://example.com/keys", + HeaderName: "Authorization", + ProviderOptions: &idp.Options{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + }) + logging.OnError(err).Panic("create jwt idp") + return resp.GetId() +} + func (i *Instance) CreateIntent(ctx context.Context, idpID string) *user_v2.StartIdentityProviderIntentResponse { resp, err := i.Client.UserV2.StartIdentityProviderIntent(ctx, &user_v2.StartIdentityProviderIntentRequest{ IdpId: idpID, diff --git a/internal/integration/sink/server.go b/internal/integration/sink/server.go index 8abb31a63e..653c5236d6 100644 --- a/internal/integration/sink/server.go +++ b/internal/integration/sink/server.go @@ -27,6 +27,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/idp/providers/jwt" "github.com/zitadel/zitadel/internal/idp/providers/ldap" "github.com/zitadel/zitadel/internal/idp/providers/oauth" openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" @@ -124,6 +125,25 @@ func SuccessfulLDAPIntent(instanceID, idpID, idpUserID, userID string) (string, return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil } +func SuccessfulJWTIntent(instanceID, idpID, idpUserID, userID string, expiry time.Time) (string, string, time.Time, uint64, error) { + u := url.URL{ + Scheme: "http", + Host: host, + Path: successfulIntentJWTPath(), + } + resp, err := callIntent(u.String(), &SuccessfulIntentRequest{ + InstanceID: instanceID, + IDPID: idpID, + IDPUserID: idpUserID, + UserID: userID, + Expiry: expiry, + }) + if err != nil { + return "", "", time.Time{}, uint64(0), err + } + return resp.IntentID, resp.Token, resp.ChangeDate, resp.Sequence, nil +} + // StartServer starts a simple HTTP server on localhost:8081 // ZITADEL can use the server to send HTTP requests which can be // used to validate tests through [Subscribe]rs. @@ -145,6 +165,7 @@ func StartServer(commands *command.Commands) (close func()) { router.HandleFunc(successfulIntentOIDCPath(), successfulIntentHandler(commands, createSuccessfulOIDCIntent)) router.HandleFunc(successfulIntentSAMLPath(), successfulIntentHandler(commands, createSuccessfulSAMLIntent)) router.HandleFunc(successfulIntentLDAPPath(), successfulIntentHandler(commands, createSuccessfulLDAPIntent)) + router.HandleFunc(successfulIntentJWTPath(), successfulIntentHandler(commands, createSuccessfulJWTIntent)) } s := &http.Server{ Addr: listenAddr, @@ -195,6 +216,10 @@ func successfulIntentLDAPPath() string { return path.Join(successfulIntentPath(), "/", "ldap") } +func successfulIntentJWTPath() string { + return path.Join(successfulIntentPath(), "/", "jwt") +} + // forwarder handles incoming HTTP requests from ZITADEL and // forwards them to all subscribed web sockets. type forwarder struct { @@ -497,3 +522,30 @@ func createSuccessfulLDAPIntent(ctx context.Context, cmd *command.Commands, req writeModel.ProcessedSequence, }, nil } + +func createSuccessfulJWTIntent(ctx context.Context, cmd *command.Commands, req *SuccessfulIntentRequest) (*SuccessfulIntentResponse, error) { + intentID, err := createIntent(ctx, cmd, req.InstanceID, req.IDPID) + writeModel, err := cmd.GetIntentWriteModel(ctx, intentID, req.InstanceID) + idpUser := &jwt.User{ + IDTokenClaims: &oidc.IDTokenClaims{ + TokenClaims: oidc.TokenClaims{ + Subject: req.IDPUserID, + }, + }, + } + session := &jwt.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + IDToken: "idToken", + }, + } + token, err := cmd.SucceedIDPIntent(ctx, writeModel, idpUser, session, req.UserID) + if err != nil { + return nil, err + } + return &SuccessfulIntentResponse{ + intentID, + token, + writeModel.ChangeDate, + writeModel.ProcessedSequence, + }, nil +} From 77b433367ef8ac643e5988d54d9a9ce30e127ddc Mon Sep 17 00:00:00 2001 From: Connor <80653133+connorHashDash@users.noreply.github.com> Date: Wed, 28 May 2025 07:06:27 +0100 Subject: [PATCH 068/123] fix(login): Copy to clipboard button in MFA login step now compatible in non-chrome browser (#9880) related to issue [#9379](https://github.com/zitadel/zitadel/issues/9379) # Which Problems Are Solved Copy to clipboard button was not compatible with Webkit/ Firefox browsers. # How the Problems Are Solved The previous function used addEventListener without a callback function as a second argument. I simply added the callback function and left existing code intact to fix the bug. # Additional Changes Added `type=button` to prevent submitting the form when clicking the button. # Additional Context none --------- Co-authored-by: Livio Spring --- .../api/ui/login/static/resources/scripts/copy_to_clipboard.js | 2 +- internal/api/ui/login/static/templates/mfa_init_otp.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/ui/login/static/resources/scripts/copy_to_clipboard.js b/internal/api/ui/login/static/resources/scripts/copy_to_clipboard.js index 97359cdde5..848aa0742b 100644 --- a/internal/api/ui/login/static/resources/scripts/copy_to_clipboard.js +++ b/internal/api/ui/login/static/resources/scripts/copy_to_clipboard.js @@ -3,4 +3,4 @@ const copyToClipboard = str => { }; let copyButton = document.getElementById("copy"); -copyButton.addEventListener("click", copyToClipboard(copyButton.getAttribute("data-copy"))); +copyButton.addEventListener("click", () => copyToClipboard(copyButton.getAttribute("data-copy"))); diff --git a/internal/api/ui/login/static/templates/mfa_init_otp.html b/internal/api/ui/login/static/templates/mfa_init_otp.html index b84a88a5b1..a9ae31dcdd 100644 --- a/internal/api/ui/login/static/templates/mfa_init_otp.html +++ b/internal/api/ui/login/static/templates/mfa_init_otp.html @@ -28,7 +28,7 @@

From c097887bc5f680e12c998580fb56d98a15758f53 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 28 May 2025 10:12:27 +0200 Subject: [PATCH 069/123] fix: validate proto header and provide https enforcement (#9975) # Which Problems Are Solved ZITADEL uses the notification triggering requests Forwarded or X-Forwarded-Proto header to build the button link sent in emails for confirming a password reset with the emailed code. If this header is overwritten and a user clicks the link to a malicious site in the email, the secret code can be retrieved and used to reset the users password and take over his account. Accounts with MFA or Passwordless enabled can not be taken over by this attack. # How the Problems Are Solved - The `X-Forwarded-Proto` and `proto` of the Forwarded headers are validated (http / https). - Additionally, when exposing ZITADEL through https. An overwrite to http is no longer possible. # Additional Changes None # Additional Context None --- .../api/http/middleware/origin_interceptor.go | 32 +++++++------- .../middleware/origin_interceptor_test.go | 42 +++++++++---------- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/internal/api/http/middleware/origin_interceptor.go b/internal/api/http/middleware/origin_interceptor.go index 35af8770b7..607855b80f 100644 --- a/internal/api/http/middleware/origin_interceptor.go +++ b/internal/api/http/middleware/origin_interceptor.go @@ -10,12 +10,12 @@ import ( http_util "github.com/zitadel/zitadel/internal/api/http" ) -func WithOrigin(fallBackToHttps bool, http1Header, http2Header string, instanceHostHeaders, publicDomainHeaders []string) mux.MiddlewareFunc { +func WithOrigin(enforceHttps bool, http1Header, http2Header string, instanceHostHeaders, publicDomainHeaders []string) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := composeDomainContext( r, - fallBackToHttps, + enforceHttps, // to make sure we don't break existing configurations we append the existing checked headers as well slices.Compact(append(instanceHostHeaders, http1Header, http2Header, http_util.Forwarded, http_util.ZitadelForwarded, http_util.ForwardedFor, http_util.ForwardedHost, http_util.ForwardedProto)), publicDomainHeaders, @@ -25,28 +25,32 @@ func WithOrigin(fallBackToHttps bool, http1Header, http2Header string, instanceH } } -func composeDomainContext(r *http.Request, fallBackToHttps bool, instanceDomainHeaders, publicDomainHeaders []string) *http_util.DomainCtx { +func composeDomainContext(r *http.Request, enforceHttps bool, instanceDomainHeaders, publicDomainHeaders []string) *http_util.DomainCtx { instanceHost, instanceProto := hostFromRequest(r, instanceDomainHeaders) publicHost, publicProto := hostFromRequest(r, publicDomainHeaders) - if publicProto == "" { - publicProto = instanceProto - } - if publicProto == "" { - publicProto = "http" - if fallBackToHttps { - publicProto = "https" - } - } if instanceHost == "" { instanceHost = r.Host } return &http_util.DomainCtx{ InstanceHost: instanceHost, - Protocol: publicProto, + Protocol: protocolFromRequest(instanceProto, publicProto, enforceHttps), PublicHost: publicHost, } } +func protocolFromRequest(instanceProto, publicProto string, enforceHttps bool) string { + if enforceHttps { + return "https" + } + if publicProto != "" { + return publicProto + } + if instanceProto != "" { + return instanceProto + } + return "http" +} + func hostFromRequest(r *http.Request, headers []string) (host, proto string) { var hostFromHeader, protoFromHeader string for _, header := range headers { @@ -65,7 +69,7 @@ func hostFromRequest(r *http.Request, headers []string) (host, proto string) { if host == "" { host = hostFromHeader } - if proto == "" { + if proto == "" && (protoFromHeader == "http" || protoFromHeader == "https") { proto = protoFromHeader } } diff --git a/internal/api/http/middleware/origin_interceptor_test.go b/internal/api/http/middleware/origin_interceptor_test.go index 989e4d48b3..7419c91aba 100644 --- a/internal/api/http/middleware/origin_interceptor_test.go +++ b/internal/api/http/middleware/origin_interceptor_test.go @@ -11,8 +11,8 @@ import ( func Test_composeOrigin(t *testing.T) { type args struct { - h http.Header - fallBackToHttps bool + h http.Header + enforceHttps bool } tests := []struct { name string @@ -30,7 +30,7 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "Forwarded": []string{"proto=https"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "host.header", @@ -42,7 +42,7 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "Forwarded": []string{"host=forwarded.host"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "forwarded.host", @@ -54,7 +54,7 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "Forwarded": []string{"proto=https;host=forwarded.host"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "forwarded.host", @@ -66,7 +66,7 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "Forwarded": []string{"proto=https;host=forwarded.host, proto=http;host=forwarded.host2"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "forwarded.host", @@ -78,7 +78,7 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "Forwarded": []string{"proto=https;host=forwarded.host, proto=http"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "forwarded.host", @@ -90,11 +90,11 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "Forwarded": []string{"proto=http", "proto=https;host=forwarded.host", "proto=http"}, }, - fallBackToHttps: true, + enforceHttps: true, }, want: &http_util.DomainCtx{ InstanceHost: "forwarded.host", - Protocol: "http", + Protocol: "https", }, }, { name: "x-forwarded-proto https", @@ -102,7 +102,7 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "X-Forwarded-Proto": []string{"https"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "host.header", @@ -114,25 +114,25 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "X-Forwarded-Proto": []string{"http"}, }, - fallBackToHttps: true, + enforceHttps: true, }, want: &http_util.DomainCtx{ InstanceHost: "host.header", - Protocol: "http", + Protocol: "https", }, }, { name: "fallback to http", args: args{ - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "host.header", Protocol: "http", }, }, { - name: "fallback to https", + name: "enforce https", args: args{ - fallBackToHttps: true, + enforceHttps: true, }, want: &http_util.DomainCtx{ InstanceHost: "host.header", @@ -144,7 +144,7 @@ func Test_composeOrigin(t *testing.T) { h: http.Header{ "X-Forwarded-Host": []string{"x-forwarded.host"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "x-forwarded.host", @@ -157,7 +157,7 @@ func Test_composeOrigin(t *testing.T) { "X-Forwarded-Proto": []string{"https"}, "X-Forwarded-Host": []string{"x-forwarded.host"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "x-forwarded.host", @@ -170,7 +170,7 @@ func Test_composeOrigin(t *testing.T) { "Forwarded": []string{"host=forwarded.host"}, "X-Forwarded-Host": []string{"x-forwarded.host"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "forwarded.host", @@ -183,7 +183,7 @@ func Test_composeOrigin(t *testing.T) { "Forwarded": []string{"host=forwarded.host"}, "X-Forwarded-Proto": []string{"https"}, }, - fallBackToHttps: false, + enforceHttps: false, }, want: &http_util.DomainCtx{ InstanceHost: "forwarded.host", @@ -198,10 +198,10 @@ func Test_composeOrigin(t *testing.T) { Host: "host.header", Header: tt.args.h, }, - tt.args.fallBackToHttps, + tt.args.enforceHttps, []string{http_util.Forwarded, http_util.ForwardedFor, http_util.ForwardedHost, http_util.ForwardedProto}, []string{"x-zitadel-public-host"}, - ), "headers: %+v, fallBackToHttps: %t", tt.args.h, tt.args.fallBackToHttps) + ), "headers: %+v, enforceHttps: %t", tt.args.h, tt.args.enforceHttps) }) } } From 046b165db85f397c4a437c4112be34695cfd5f58 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 28 May 2025 10:47:42 +0200 Subject: [PATCH 070/123] docs(a10016): add versions for v2.66 - v3 (#9908) # Which Problems Are Solved versions were missing in https://github.com/zitadel/zitadel/pull/9882 # How the Problems Are Solved added versions for 2.66.x, 2.67.x, 2.68.x, 2.69.x, 2.70.x, 2.71.x, 3.x # Additional Context can be merged after: - https://github.com/zitadel/zitadel/pull/9901 - https://github.com/zitadel/zitadel/pull/9903 - https://github.com/zitadel/zitadel/pull/9904 - https://github.com/zitadel/zitadel/pull/9907 - https://github.com/zitadel/zitadel/pull/9905 - https://github.com/zitadel/zitadel/pull/9906 - https://github.com/zitadel/zitadel/pull/9916 --- docs/docs/support/advisory/a10016.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/docs/support/advisory/a10016.md b/docs/docs/support/advisory/a10016.md index 794c354e42..84dd1cd34c 100644 --- a/docs/docs/support/advisory/a10016.md +++ b/docs/docs/support/advisory/a10016.md @@ -4,15 +4,19 @@ title: Technical Advisory 10016 ## Date -Versions:[^1] - - v2.65.x: > v2.65.9 +- v2.66.x > v2.66.17 +- v2.67.x > v2.67.14 +- v2.68.x > v2.68.10 +- v2.69.x > v2.69.10 +- v2.70.x > v2.70.11 +- v2.71.x > v2.71.10 +- v3.x > v3.2.1 + Date: 2025-05-14 -Last updated: 2025-05-14 - -[^1]: The mentioned fix is being rolled out gradually on multiple patch releases of Zitadel. This advisory will be updated as we release these versions. +Last updated: 2025-05-19 ## Description From 131f70db3423b80da1b038a822da59c908e4ffa6 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Wed, 28 May 2025 23:54:18 +0200 Subject: [PATCH 071/123] fix(eventstore): use decimal, correct mirror (#9914) # Eventstore fixes - `event.Position` used float64 before which can lead to [precision loss](https://github.com/golang/go/issues/47300). The type got replaced by [a type without precision loss](https://github.com/jackc/pgx-shopspring-decimal) - the handler reported the wrong error if the current state was updated and therefore took longer to retry failed events. # Mirror fixes - max age of auth requests can be configured to speed up copying data from `auth.auth_requests` table. Auth requests last updated before the set age will be ignored. Default is 1 month - notification projections are skipped because notifications should be sent by the source system. The projections are set to the latest position - ensure that mirror can be executed multiple times --------- Co-authored-by: Livio Spring --- cmd/mirror/event.go | 4 +- cmd/mirror/event_store.go | 14 ++++-- cmd/mirror/projections.go | 7 +++ go.mod | 2 + go.sum | 4 ++ .../eventsourcing/handler/handler.go | 16 +++++-- internal/api/oidc/key.go | 17 +++---- internal/api/saml/certificate.go | 17 +++---- .../eventsourcing/handler/handler.go | 16 +++++-- internal/database/database.go | 4 ++ internal/database/dialect/connections.go | 8 +++- internal/eventstore/event.go | 4 +- internal/eventstore/event_base.go | 5 +- internal/eventstore/eventstore.go | 17 +++++-- .../eventstore/eventstore_querier_test.go | 16 ++++--- internal/eventstore/eventstore_test.go | 17 +++---- .../eventstore/handler/v2/field_handler.go | 18 ++++--- internal/eventstore/handler/v2/handler.go | 32 +++++++++---- internal/eventstore/handler/v2/state.go | 8 ++-- internal/eventstore/handler/v2/state_test.go | 13 ++--- internal/eventstore/handler/v2/statement.go | 5 +- internal/eventstore/local_postgres_test.go | 19 ++++++-- internal/eventstore/read_model.go | 22 +++++---- internal/eventstore/repository/event.go | 5 +- .../repository/mock/repository.mock.go | 15 +++--- .../repository/mock/repository.mock.impl.go | 5 +- .../eventstore/repository/search_query.go | 10 ++-- .../repository/sql/local_postgres_test.go | 8 +++- .../eventstore/repository/sql/postgres.go | 14 +++--- .../repository/sql/postgres_test.go | 4 +- internal/eventstore/repository/sql/query.go | 25 +++++----- .../eventstore/repository/sql/query_test.go | 44 +++++++++-------- internal/eventstore/search_query.go | 24 +++++----- internal/eventstore/search_query_test.go | 4 +- internal/eventstore/v1/models/event.go | 6 ++- internal/eventstore/v3/event.go | 7 +-- internal/notification/projections.go | 20 ++++++++ internal/query/access_token.go | 5 +- internal/query/current_state.go | 11 +++-- internal/query/current_state_test.go | 8 ++-- internal/query/projection/projection.go | 38 +++++++++++---- internal/query/user_grant.go | 4 +- internal/query/user_membership.go | 4 +- internal/v2/database/number_filter.go | 3 +- internal/v2/eventstore/event_store.go | 6 ++- internal/v2/eventstore/postgres/push_test.go | 24 +++++----- internal/v2/eventstore/postgres/query_test.go | 48 ++++++++++--------- internal/v2/eventstore/query.go | 6 ++- internal/v2/eventstore/query_test.go | 26 +++++----- .../v2/readmodel/last_successful_mirror.go | 7 ++- internal/v2/system/mirror/succeeded.go | 6 ++- 51 files changed, 436 insertions(+), 236 deletions(-) diff --git a/cmd/mirror/event.go b/cmd/mirror/event.go index d513990e10..567fb659a2 100644 --- a/cmd/mirror/event.go +++ b/cmd/mirror/event.go @@ -3,6 +3,8 @@ package mirror import ( "context" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/v2/readmodel" "github.com/zitadel/zitadel/internal/v2/system" @@ -29,7 +31,7 @@ func queryLastSuccessfulMigration(ctx context.Context, destinationES *eventstore return lastSuccess, nil } -func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position float64) error { +func writeMigrationSucceeded(ctx context.Context, destinationES *eventstore.EventStore, id, source string, position decimal.Decimal) error { return destinationES.Push( ctx, eventstore.NewPushIntent( diff --git a/cmd/mirror/event_store.go b/cmd/mirror/event_store.go index 41c529c025..be14abe340 100644 --- a/cmd/mirror/event_store.go +++ b/cmd/mirror/event_store.go @@ -8,7 +8,9 @@ import ( "io" "time" + "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/stdlib" + "github.com/shopspring/decimal" "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zitadel/logging" @@ -89,7 +91,7 @@ func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { previousMigration, err := queryLastSuccessfulMigration(ctx, destinationES, source.DatabaseName()) logging.OnError(err).Fatal("unable to query latest successful migration") - var maxPosition float64 + var maxPosition decimal.Decimal err = source.QueryRowContext(ctx, func(row *sql.Row) error { return row.Scan(&maxPosition) @@ -101,7 +103,7 @@ func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { logging.WithFields("from", previousMigration.Position, "to", maxPosition).Info("start event migration") nextPos := make(chan bool, 1) - pos := make(chan float64, 1) + pos := make(chan decimal.Decimal, 1) errs := make(chan error, 3) go func() { @@ -152,7 +154,7 @@ func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { go func() { defer close(pos) for range nextPos { - var position float64 + var position decimal.Decimal err := dest.QueryRowContext( ctx, func(row *sql.Row) error { @@ -175,6 +177,10 @@ func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { tag, err := conn.PgConn().CopyFrom(ctx, reader, "COPY eventstore.events2 FROM STDIN") eventCount = tag.RowsAffected() if err != nil { + pgErr := new(pgconn.PgError) + errors.As(err, &pgErr) + + logging.WithError(err).WithField("pg_err_details", pgErr.Detail).Error("unable to copy events into destination") return zerrors.ThrowUnknown(err, "MIGRA-DTHi7", "unable to copy events into destination") } @@ -187,7 +193,7 @@ func copyEvents(ctx context.Context, source, dest *db.DB, bulkSize uint32) { 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) { +func writeCopyEventsDone(ctx context.Context, es *eventstore.EventStore, id, source string, position decimal.Decimal, errs <-chan error) { joinedErrs := make([]error, 0, len(errs)) for err := range errs { joinedErrs = append(joinedErrs, err) diff --git a/cmd/mirror/projections.go b/cmd/mirror/projections.go index 4e12b29748..0ff4356d6f 100644 --- a/cmd/mirror/projections.go +++ b/cmd/mirror/projections.go @@ -296,6 +296,13 @@ func execProjections(ctx context.Context, instances <-chan string, failedInstanc continue } + err = projection.ProjectInstanceFields(ctx) + if err != nil { + logging.WithFields("instance", instance).WithError(err).Info("trigger fields failed") + failedInstances <- instance + continue + } + err = auth_handler.ProjectInstance(ctx) if err != nil { logging.WithFields("instance", instance).WithError(err).Info("trigger auth handler failed") diff --git a/go.mod b/go.mod index ec86708942..c1cbf2dd77 100644 --- a/go.mod +++ b/go.mod @@ -45,6 +45,7 @@ require ( github.com/h2non/gock v1.2.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/improbable-eng/grpc-web v0.15.0 + github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e github.com/jackc/pgx/v5 v5.7.5 github.com/jarcoal/jpath v0.0.0-20140328210829-f76b8b2dbf52 github.com/jinzhu/gorm v1.9.16 @@ -65,6 +66,7 @@ require ( github.com/riverqueue/river/rivertype v0.22.0 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 + github.com/shopspring/decimal v1.3.1 github.com/sony/gobreaker/v2 v2.1.0 github.com/sony/sonyflake v1.2.1 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index 6d54730acd..cc3bc35841 100644 --- a/go.sum +++ b/go.sum @@ -442,6 +442,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e h1:i3gQ/Zo7sk4LUVbsAjTNeC4gIjoPNIZVzs4EXstssV4= +github.com/jackc/pgx-shopspring-decimal v0.0.0-20220624020537-1d36b5a1853e/go.mod h1:zUHglCZ4mpDUPgIwqEKoba6+tcUQzRdb1+DPTuYe9pI= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= @@ -705,6 +707,8 @@ github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= diff --git a/internal/admin/repository/eventsourcing/handler/handler.go b/internal/admin/repository/eventsourcing/handler/handler.go index 76584b55b0..b38e890e66 100644 --- a/internal/admin/repository/eventsourcing/handler/handler.go +++ b/internal/admin/repository/eventsourcing/handler/handler.go @@ -2,9 +2,11 @@ package handler import ( "context" + "errors" "fmt" "time" + "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/admin/repository/eventsourcing/view" @@ -63,9 +65,17 @@ func Start(ctx context.Context) { func ProjectInstance(ctx context.Context) error { for i, projection := range projections { logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting admin projection") - _, err := projection.Trigger(ctx) - if err != nil { - return err + for { + _, err := projection.Trigger(ctx) + if err == nil { + break + } + var pgErr *pgconn.PgError + errors.As(err, &pgErr) + if pgErr.Code != database.PgUniqueConstraintErrorCode { + return err + } + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID()).WithError(err).Debug("admin projection failed because of unique constraint, retrying") } logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("admin projection done") } diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index 81f3b1c466..852bbc7db8 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -11,6 +11,7 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/jonboulle/clockwork" "github.com/muhlemmer/gu" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" @@ -350,14 +351,14 @@ func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) { if len(keys.Keys) > 0 { return PrivateKeyToSigningKey(SelectSigningKey(keys.Keys), o.encAlg) } - var position float64 + var position decimal.Decimal if keys.State != nil { position = keys.State.Position } return nil, o.refreshSigningKey(ctx, position) } -func (o *OPStorage) refreshSigningKey(ctx context.Context, position float64) error { +func (o *OPStorage) refreshSigningKey(ctx context.Context, position decimal.Decimal) error { ok, err := o.ensureIsLatestKey(ctx, position) if err != nil || !ok { return zerrors.ThrowInternal(err, "OIDC-ASfh3", "cannot ensure that projection is up to date") @@ -369,12 +370,12 @@ func (o *OPStorage) refreshSigningKey(ctx context.Context, position float64) err return zerrors.ThrowInternal(nil, "OIDC-Df1bh", "") } -func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position float64) (bool, error) { - maxSequence, err := o.getMaxKeySequence(ctx) +func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position decimal.Decimal) (bool, error) { + maxSequence, err := o.getMaxKeyPosition(ctx) if err != nil { return false, fmt.Errorf("error retrieving new events: %w", err) } - return position >= maxSequence, nil + return position.GreaterThanOrEqual(maxSequence), nil } func PrivateKeyToSigningKey(key query.PrivateKey, algorithm crypto.EncryptionAlgorithm) (_ op.SigningKey, err error) { @@ -412,9 +413,9 @@ func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context) error { return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), "RS256") } -func (o *OPStorage) getMaxKeySequence(ctx context.Context) (float64, error) { - return o.eventstore.LatestSequence(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). +func (o *OPStorage) getMaxKeyPosition(ctx context.Context) (decimal.Decimal, error) { + return o.eventstore.LatestPosition(ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). ResourceOwner(authz.GetInstance(ctx).InstanceID()). AwaitOpenTransactions(). AddQuery(). diff --git a/internal/api/saml/certificate.go b/internal/api/saml/certificate.go index ff130f7709..14752cd5cd 100644 --- a/internal/api/saml/certificate.go +++ b/internal/api/saml/certificate.go @@ -6,6 +6,7 @@ import ( "time" "github.com/go-jose/go-jose/v4" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/saml/pkg/provider/key" @@ -76,7 +77,7 @@ func (p *Storage) getCertificateAndKey(ctx context.Context, usage crypto.KeyUsag return p.certificateToCertificateAndKey(selectCertificate(certs.Certificates)) } - var position float64 + var position decimal.Decimal if certs.State != nil { position = certs.State.Position } @@ -87,7 +88,7 @@ func (p *Storage) getCertificateAndKey(ctx context.Context, usage crypto.KeyUsag func (p *Storage) refreshCertificate( ctx context.Context, usage crypto.KeyUsage, - position float64, + position decimal.Decimal, ) error { ok, err := p.ensureIsLatestCertificate(ctx, position) if err != nil { @@ -103,12 +104,12 @@ func (p *Storage) refreshCertificate( return nil } -func (p *Storage) ensureIsLatestCertificate(ctx context.Context, position float64) (bool, error) { - maxSequence, err := p.getMaxKeySequence(ctx) +func (p *Storage) ensureIsLatestCertificate(ctx context.Context, position decimal.Decimal) (bool, error) { + maxSequence, err := p.getMaxKeyPosition(ctx) if err != nil { return false, fmt.Errorf("error retrieving new events: %w", err) } - return position >= maxSequence, nil + return position.GreaterThanOrEqual(maxSequence), nil } func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage crypto.KeyUsage) error { @@ -151,9 +152,9 @@ func (p *Storage) lockAndGenerateCertificateAndKey(ctx context.Context, usage cr } } -func (p *Storage) getMaxKeySequence(ctx context.Context) (float64, error) { - return p.eventstore.LatestSequence(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). +func (p *Storage) getMaxKeyPosition(ctx context.Context) (decimal.Decimal, error) { + return p.eventstore.LatestPosition(ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). ResourceOwner(authz.GetInstance(ctx).InstanceID()). AwaitOpenTransactions(). AddQuery(). diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go index 74a27a8312..0c151bb412 100644 --- a/internal/auth/repository/eventsourcing/handler/handler.go +++ b/internal/auth/repository/eventsourcing/handler/handler.go @@ -2,9 +2,11 @@ package handler import ( "context" + "errors" "fmt" "time" + "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -78,9 +80,17 @@ func Projections() []*handler2.Handler { func ProjectInstance(ctx context.Context) error { for i, projection := range projections { logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting auth projection") - _, err := projection.Trigger(ctx) - if err != nil { - return err + for { + _, err := projection.Trigger(ctx) + if err == nil { + break + } + var pgErr *pgconn.PgError + errors.As(err, &pgErr) + if pgErr.Code != database.PgUniqueConstraintErrorCode { + return err + } + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID()).WithError(err).Debug("auth projection failed because of unique constraint, retrying") } logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("auth projection done") } diff --git a/internal/database/database.go b/internal/database/database.go index ddc26a7961..b40715d6b5 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -64,6 +64,10 @@ func CloseTransaction(tx Tx, err error) error { return commitErr } +const ( + PgUniqueConstraintErrorCode = "23505" +) + type Config struct { Dialects map[string]interface{} `mapstructure:",remain"` connector dialect.Connector diff --git a/internal/database/dialect/connections.go b/internal/database/dialect/connections.go index 11b2681fea..a5c90b4059 100644 --- a/internal/database/dialect/connections.go +++ b/internal/database/dialect/connections.go @@ -5,6 +5,7 @@ import ( "errors" "reflect" + pgxdecimal "github.com/jackc/pgx-shopspring-decimal" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) @@ -23,7 +24,12 @@ type ConnectionConfig struct { AfterRelease []func(c *pgx.Conn) error } -var afterConnectFuncs []func(ctx context.Context, c *pgx.Conn) error +var afterConnectFuncs = []func(ctx context.Context, c *pgx.Conn) error{ + func(ctx context.Context, c *pgx.Conn) error { + pgxdecimal.Register(c.TypeMap()) + return nil + }, +} func RegisterAfterConnect(f func(ctx context.Context, c *pgx.Conn) error) { afterConnectFuncs = append(afterConnectFuncs, f) diff --git a/internal/eventstore/event.go b/internal/eventstore/event.go index 3df096f069..656a02f33d 100644 --- a/internal/eventstore/event.go +++ b/internal/eventstore/event.go @@ -5,6 +5,8 @@ import ( "reflect" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/zerrors" ) @@ -44,7 +46,7 @@ type Event interface { // CreatedAt is the time the event was created at CreatedAt() time.Time // Position is the global position of the event - Position() float64 + Position() decimal.Decimal // Unmarshal parses the payload and stores the result // in the value pointed to by ptr. If ptr is nil or not a pointer, diff --git a/internal/eventstore/event_base.go b/internal/eventstore/event_base.go index ed81e95320..6a911bc0eb 100644 --- a/internal/eventstore/event_base.go +++ b/internal/eventstore/event_base.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -25,7 +26,7 @@ type BaseEvent struct { Agg *Aggregate `json:"-"` Seq uint64 - Pos float64 + Pos decimal.Decimal Creation time.Time previousAggregateSequence uint64 previousAggregateTypeSequence uint64 @@ -38,7 +39,7 @@ type BaseEvent struct { } // Position implements Event. -func (e *BaseEvent) Position() float64 { +func (e *BaseEvent) Position() decimal.Decimal { return e.Pos } diff --git a/internal/eventstore/eventstore.go b/internal/eventstore/eventstore.go index 4954df86c8..8a8d32bc43 100644 --- a/internal/eventstore/eventstore.go +++ b/internal/eventstore/eventstore.go @@ -7,6 +7,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -14,6 +15,12 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +func init() { + // this is needed to ensure that position is marshaled as a number + // otherwise it will be marshaled as a string + decimal.MarshalJSONWithoutQuotes = true +} + // Eventstore abstracts all functions needed to store valid events // and filters the stored events type Eventstore struct { @@ -229,11 +236,11 @@ func (es *Eventstore) FilterToReducer(ctx context.Context, searchQuery *SearchQu }) } -// LatestSequence filters the latest sequence for the given search query -func (es *Eventstore) LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) { +// LatestPosition filters the latest position for the given search query +func (es *Eventstore) LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) { queryFactory.InstanceID(authz.GetInstance(ctx).InstanceID()) - return es.querier.LatestSequence(ctx, queryFactory) + return es.querier.LatestPosition(ctx, queryFactory) } // InstanceIDs returns the distinct instance ids found by the search query @@ -265,8 +272,8 @@ type Querier interface { Health(ctx context.Context) error // FilterToReducer calls r for every event returned from the storage FilterToReducer(ctx context.Context, searchQuery *SearchQueryBuilder, r Reducer) error - // LatestSequence returns the latest sequence found by the search query - LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) + // LatestPosition returns the latest position found by the search query + LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) // InstanceIDs returns the instance ids found by the search query InstanceIDs(ctx context.Context, queryFactory *SearchQueryBuilder) ([]string, error) // Client returns the underlying database connection diff --git a/internal/eventstore/eventstore_querier_test.go b/internal/eventstore/eventstore_querier_test.go index 3f23c5da75..88797a835e 100644 --- a/internal/eventstore/eventstore_querier_test.go +++ b/internal/eventstore/eventstore_querier_test.go @@ -4,6 +4,8 @@ import ( "context" "testing" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/eventstore" ) @@ -131,7 +133,7 @@ func TestEventstore_Filter(t *testing.T) { } } -func TestEventstore_LatestSequence(t *testing.T) { +func TestEventstore_LatestPosition(t *testing.T) { type args struct { searchQuery *eventstore.SearchQueryBuilder } @@ -139,7 +141,7 @@ func TestEventstore_LatestSequence(t *testing.T) { existingEvents []eventstore.Command } type res struct { - sequence float64 + position decimal.Decimal } tests := []struct { name string @@ -151,7 +153,7 @@ func TestEventstore_LatestSequence(t *testing.T) { { name: "aggregate type filter no sequence", args: args{ - searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). AddQuery(). AggregateTypes("not found"). Builder(), @@ -168,7 +170,7 @@ func TestEventstore_LatestSequence(t *testing.T) { { name: "aggregate type filter sequence", args: args{ - searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxSequence). + searchQuery: eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). AddQuery(). AggregateTypes(eventstore.AggregateType(t.Name())). Builder(), @@ -202,12 +204,12 @@ func TestEventstore_LatestSequence(t *testing.T) { return } - sequence, err := db.LatestSequence(context.Background(), tt.args.searchQuery) + position, err := db.LatestPosition(context.Background(), tt.args.searchQuery) if (err != nil) != tt.wantErr { t.Errorf("eventstore.query() error = %v, wantErr %v", err, tt.wantErr) } - if tt.res.sequence > sequence { - t.Errorf("eventstore.query() expected sequence: %v got %v", tt.res.sequence, sequence) + if tt.res.position.GreaterThan(position) { + t.Errorf("eventstore.query() expected position: %v got %v", tt.res.position, position) } }) } diff --git a/internal/eventstore/eventstore_test.go b/internal/eventstore/eventstore_test.go index 9e1aa77db1..5452572faa 100644 --- a/internal/eventstore/eventstore_test.go +++ b/internal/eventstore/eventstore_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/service" @@ -397,7 +398,7 @@ func (repo *testPusher) Push(_ context.Context, _ database.ContextQueryExecuter, type testQuerier struct { events []Event - sequence float64 + sequence decimal.Decimal instances []string err error t *testing.T @@ -430,9 +431,9 @@ func (repo *testQuerier) FilterToReducer(ctx context.Context, searchQuery *Searc return nil } -func (repo *testQuerier) LatestSequence(ctx context.Context, queryFactory *SearchQueryBuilder) (float64, error) { +func (repo *testQuerier) LatestPosition(ctx context.Context, queryFactory *SearchQueryBuilder) (decimal.Decimal, error) { if repo.err != nil { - return 0, repo.err + return decimal.Decimal{}, repo.err } return repo.sequence, nil } @@ -1076,7 +1077,7 @@ func TestEventstore_FilterEvents(t *testing.T) { } } -func TestEventstore_LatestSequence(t *testing.T) { +func TestEventstore_LatestPosition(t *testing.T) { type args struct { query *SearchQueryBuilder } @@ -1096,7 +1097,7 @@ func TestEventstore_LatestSequence(t *testing.T) { name: "no events", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1119,7 +1120,7 @@ func TestEventstore_LatestSequence(t *testing.T) { name: "repo error", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1142,7 +1143,7 @@ func TestEventstore_LatestSequence(t *testing.T) { name: "found events", args: args{ query: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, queries: []*SearchQuery{ { builder: &SearchQueryBuilder{}, @@ -1168,7 +1169,7 @@ func TestEventstore_LatestSequence(t *testing.T) { querier: tt.fields.repo, } - _, err := es.LatestSequence(context.Background(), tt.args.query) + _, err := es.LatestPosition(context.Background(), tt.args.query) if (err != nil) != tt.res.wantErr { t.Errorf("Eventstore.aggregatesToEvents() error = %v, wantErr %v", err, tt.res.wantErr) } diff --git a/internal/eventstore/handler/v2/field_handler.go b/internal/eventstore/handler/v2/field_handler.go index ad309ac790..3c25731c83 100644 --- a/internal/eventstore/handler/v2/field_handler.go +++ b/internal/eventstore/handler/v2/field_handler.go @@ -8,6 +8,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/eventstore" ) @@ -126,10 +127,15 @@ func (h *FieldHandler) processEvents(ctx context.Context, config *triggerConfig) return additionalIteration, err } // stop execution if currentState.eventTimestamp >= config.maxCreatedAt - if config.maxPosition != 0 && currentState.position >= config.maxPosition { + if !config.maxPosition.IsZero() && currentState.position.GreaterThanOrEqual(config.maxPosition) { return false, nil } + if config.minPosition.GreaterThan(decimal.NewFromInt(0)) { + currentState.position = config.minPosition + currentState.offset = 0 + } + events, additionalIteration, err := h.fetchEvents(ctx, tx, currentState) if err != nil { return additionalIteration, err @@ -159,7 +165,7 @@ func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState idx, offset := skipPreviouslyReducedEvents(events, currentState) - if currentState.position == events[len(events)-1].Position() { + if currentState.position.Equal(events[len(events)-1].Position()) { offset += currentState.offset } currentState.position = events[len(events)-1].Position() @@ -179,7 +185,7 @@ func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState fillFieldsEvents := make([]eventstore.FillFieldsEvent, len(events)) highestPosition := events[len(events)-1].Position() for i, event := range events { - if event.Position() == highestPosition { + if event.Position().Equal(highestPosition) { offset++ } fillFieldsEvents[i] = event.(eventstore.FillFieldsEvent) @@ -189,14 +195,14 @@ func (h *FieldHandler) fetchEvents(ctx context.Context, tx *sql.Tx, currentState } func skipPreviouslyReducedEvents(events []eventstore.Event, currentState *state) (index int, offset uint32) { - var position float64 + var position decimal.Decimal for i, event := range events { - if event.Position() != position { + if !event.Position().Equal(position) { offset = 0 position = event.Position() } offset++ - if event.Position() == currentState.position && + if event.Position().Equal(currentState.position) && event.Aggregate().ID == currentState.aggregateID && event.Aggregate().Type == currentState.aggregateType && event.Sequence() == currentState.sequence { diff --git a/internal/eventstore/handler/v2/handler.go b/internal/eventstore/handler/v2/handler.go index fb696ad090..fd8b206b38 100644 --- a/internal/eventstore/handler/v2/handler.go +++ b/internal/eventstore/handler/v2/handler.go @@ -4,13 +4,13 @@ import ( "context" "database/sql" "errors" - "math" "math/rand" "slices" "sync" "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -395,7 +395,8 @@ func (h *Handler) existingInstances(ctx context.Context) ([]string, error) { type triggerConfig struct { awaitRunning bool - maxPosition float64 + maxPosition decimal.Decimal + minPosition decimal.Decimal } type TriggerOpt func(conf *triggerConfig) @@ -406,12 +407,18 @@ func WithAwaitRunning() TriggerOpt { } } -func WithMaxPosition(position float64) TriggerOpt { +func WithMaxPosition(position decimal.Decimal) TriggerOpt { return func(conf *triggerConfig) { conf.maxPosition = position } } +func WithMinPosition(position decimal.Decimal) TriggerOpt { + return func(conf *triggerConfig) { + conf.minPosition = position + } +} + func (h *Handler) Trigger(ctx context.Context, opts ...TriggerOpt) (_ context.Context, err error) { config := new(triggerConfig) for _, opt := range opts { @@ -520,10 +527,15 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add return additionalIteration, err } // stop execution if currentState.position >= config.maxPosition - if config.maxPosition != 0 && currentState.position >= config.maxPosition { + if !config.maxPosition.Equal(decimal.Decimal{}) && currentState.position.GreaterThanOrEqual(config.maxPosition) { return false, nil } + if config.minPosition.GreaterThan(decimal.NewFromInt(0)) { + currentState.position = config.minPosition + currentState.offset = 0 + } + var statements []*Statement statements, additionalIteration, err = h.generateStatements(ctx, tx, currentState) if err != nil { @@ -565,7 +577,10 @@ func (h *Handler) processEvents(ctx context.Context, config *triggerConfig) (add currentState.sequence = statements[lastProcessedIndex].Sequence currentState.eventTimestamp = statements[lastProcessedIndex].CreationDate - err = h.setState(tx, currentState) + setStateErr := h.setState(tx, currentState) + if setStateErr != nil { + err = setStateErr + } return additionalIteration, err } @@ -615,7 +630,7 @@ func (h *Handler) generateStatements(ctx context.Context, tx *sql.Tx, currentSta func skipPreviouslyReducedStatements(statements []*Statement, currentState *state) int { for i, statement := range statements { - if statement.Position == currentState.position && + if statement.Position.Equal(currentState.position) && statement.Aggregate.ID == currentState.aggregateID && statement.Aggregate.Type == currentState.aggregateType && statement.Sequence == currentState.sequence { @@ -678,9 +693,8 @@ func (h *Handler) eventQuery(currentState *state) *eventstore.SearchQueryBuilder OrderAsc(). InstanceID(currentState.instanceID) - if currentState.position > 0 { - // decrease position by 10 because builder.PositionAfter filters for position > and we need position >= - builder = builder.PositionAfter(math.Float64frombits(math.Float64bits(currentState.position) - 10)) + if currentState.position.GreaterThan(decimal.Decimal{}) { + builder = builder.PositionAtLeast(currentState.position) if currentState.offset > 0 { builder = builder.Offset(currentState.offset) } diff --git a/internal/eventstore/handler/v2/state.go b/internal/eventstore/handler/v2/state.go index d3b6953488..c4afaed204 100644 --- a/internal/eventstore/handler/v2/state.go +++ b/internal/eventstore/handler/v2/state.go @@ -7,6 +7,8 @@ import ( "errors" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" @@ -14,7 +16,7 @@ import ( type state struct { instanceID string - position float64 + position decimal.Decimal eventTimestamp time.Time aggregateType eventstore.AggregateType aggregateID string @@ -45,7 +47,7 @@ func (h *Handler) currentState(ctx context.Context, tx *sql.Tx, config *triggerC aggregateType = new(sql.NullString) sequence = new(sql.NullInt64) timestamp = new(sql.NullTime) - position = new(sql.NullFloat64) + position = new(decimal.NullDecimal) offset = new(sql.NullInt64) ) @@ -75,7 +77,7 @@ func (h *Handler) currentState(ctx context.Context, tx *sql.Tx, config *triggerC currentState.aggregateType = eventstore.AggregateType(aggregateType.String) currentState.sequence = uint64(sequence.Int64) currentState.eventTimestamp = timestamp.Time - currentState.position = position.Float64 + currentState.position = position.Decimal // psql does not provide unsigned numbers so we work around it currentState.offset = uint32(offset.Int64) return currentState, nil diff --git a/internal/eventstore/handler/v2/state_test.go b/internal/eventstore/handler/v2/state_test.go index cc5fb1fbab..ef91d78e55 100644 --- a/internal/eventstore/handler/v2/state_test.go +++ b/internal/eventstore/handler/v2/state_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database/mock" @@ -166,7 +167,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: 42, + position: decimal.NewFromInt(42), }, }, isErr: func(t *testing.T, err error) { @@ -192,7 +193,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: 42, + position: decimal.NewFromInt(42), }, }, isErr: func(t *testing.T, err error) { @@ -217,7 +218,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { eventstore.AggregateType("aggregate type"), uint64(42), mock.AnyType[time.Time]{}, - float64(42), + decimal.NewFromInt(42), uint32(0), ), mock.WithExecRowsAffected(1), @@ -228,7 +229,7 @@ func TestHandler_updateLastUpdated(t *testing.T) { updatedState: &state{ instanceID: "instance", eventTimestamp: time.Now(), - position: 42, + position: decimal.NewFromInt(42), aggregateType: "aggregate type", aggregateID: "aggregate id", sequence: 42, @@ -397,7 +398,7 @@ func TestHandler_currentState(t *testing.T) { "aggregate type", int64(42), testTime, - float64(42), + decimal.NewFromInt(42).String(), uint16(10), }, }, @@ -412,7 +413,7 @@ func TestHandler_currentState(t *testing.T) { currentState: &state{ instanceID: "instance", eventTimestamp: testTime, - position: 42, + position: decimal.NewFromInt(42), aggregateType: "aggregate type", aggregateID: "aggregate id", sequence: 42, diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index a02e5d3580..5024c8c945 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "golang.org/x/exp/constraints" @@ -52,7 +53,7 @@ func (h *Handler) eventsToStatements(tx *sql.Tx, events []eventstore.Event, curr return statements, err } offset++ - if previousPosition != event.Position() { + if !previousPosition.Equal(event.Position()) { // offset is 1 because we want to skip this event offset = 1 } @@ -82,7 +83,7 @@ func (h *Handler) reduce(event eventstore.Event) (*Statement, error) { type Statement struct { Aggregate *eventstore.Aggregate Sequence uint64 - Position float64 + Position decimal.Decimal CreationDate time.Time offset uint32 diff --git a/internal/eventstore/local_postgres_test.go b/internal/eventstore/local_postgres_test.go index d75292b3ff..fdb8b4f516 100644 --- a/internal/eventstore/local_postgres_test.go +++ b/internal/eventstore/local_postgres_test.go @@ -2,12 +2,13 @@ package eventstore_test import ( "context" - "database/sql" "encoding/json" "os" "testing" "time" + pgxdecimal "github.com/jackc/pgx-shopspring-decimal" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/zitadel/logging" @@ -40,7 +41,10 @@ func TestMain(m *testing.M) { connConfig, err := pgxpool.ParseConfig(config.GetConnectionURL()) logging.OnError(err).Fatal("unable to parse db url") - connConfig.AfterConnect = new_es.RegisterEventstoreTypes + connConfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + pgxdecimal.Register(conn.TypeMap()) + return new_es.RegisterEventstoreTypes(ctx, conn) + } pool, err := pgxpool.NewWithConfig(context.Background(), connConfig) logging.OnError(err).Fatal("unable to create db pool") @@ -101,10 +105,19 @@ func initDB(ctx context.Context, db *database.DB) error { } func connectLocalhost() (*database.DB, error) { - client, err := sql.Open("pgx", "postgresql://postgres@localhost:5432/postgres?sslmode=disable") + config, err := pgxpool.ParseConfig("postgresql://postgres@localhost:5432/postgres?sslmode=disable") if err != nil { return nil, err } + config.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + pgxdecimal.Register(conn.TypeMap()) + return new_es.RegisterEventstoreTypes(ctx, conn) + } + pool, err := pgxpool.NewWithConfig(context.Background(), config) + if err != nil { + return nil, err + } + client := stdlib.OpenDBFromPool(pool) if err = client.Ping(); err != nil { return nil, err } diff --git a/internal/eventstore/read_model.go b/internal/eventstore/read_model.go index d2c755cc3a..ae77275732 100644 --- a/internal/eventstore/read_model.go +++ b/internal/eventstore/read_model.go @@ -1,19 +1,23 @@ package eventstore -import "time" +import ( + "time" + + "github.com/shopspring/decimal" +) // ReadModel is the minimum representation of a read model. // It implements a basic reducer // it might be saved in a database or in memory type ReadModel struct { - AggregateID string `json:"-"` - ProcessedSequence uint64 `json:"-"` - CreationDate time.Time `json:"-"` - ChangeDate time.Time `json:"-"` - Events []Event `json:"-"` - ResourceOwner string `json:"-"` - InstanceID string `json:"-"` - Position float64 `json:"-"` + AggregateID string `json:"-"` + ProcessedSequence uint64 `json:"-"` + CreationDate time.Time `json:"-"` + ChangeDate time.Time `json:"-"` + Events []Event `json:"-"` + ResourceOwner string `json:"-"` + InstanceID string `json:"-"` + Position decimal.Decimal `json:"-"` } // AppendEvents adds all the events to the read model. diff --git a/internal/eventstore/repository/event.go b/internal/eventstore/repository/event.go index d0d2660d79..1107649934 100644 --- a/internal/eventstore/repository/event.go +++ b/internal/eventstore/repository/event.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" @@ -22,7 +23,7 @@ type Event struct { // Seq is the sequence of the event Seq uint64 // Pos is the global sequence of the event multiple events can have the same sequence - Pos float64 + Pos decimal.Decimal //CreationDate is the time the event is created // it's used for human readability. @@ -97,7 +98,7 @@ func (e *Event) Sequence() uint64 { } // Position implements [eventstore.Event] -func (e *Event) Position() float64 { +func (e *Event) Position() decimal.Decimal { return e.Pos } diff --git a/internal/eventstore/repository/mock/repository.mock.go b/internal/eventstore/repository/mock/repository.mock.go index 8d5c0430ad..12925bc975 100644 --- a/internal/eventstore/repository/mock/repository.mock.go +++ b/internal/eventstore/repository/mock/repository.mock.go @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + decimal "github.com/shopspring/decimal" database "github.com/zitadel/zitadel/internal/database" eventstore "github.com/zitadel/zitadel/internal/eventstore" gomock "go.uber.org/mock/gomock" @@ -98,19 +99,19 @@ func (mr *MockQuerierMockRecorder) InstanceIDs(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceIDs", reflect.TypeOf((*MockQuerier)(nil).InstanceIDs), arg0, arg1) } -// LatestSequence mocks base method. -func (m *MockQuerier) LatestSequence(arg0 context.Context, arg1 *eventstore.SearchQueryBuilder) (float64, error) { +// LatestPosition mocks base method. +func (m *MockQuerier) LatestPosition(arg0 context.Context, arg1 *eventstore.SearchQueryBuilder) (decimal.Decimal, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "LatestSequence", arg0, arg1) - ret0, _ := ret[0].(float64) + ret := m.ctrl.Call(m, "LatestPosition", arg0, arg1) + ret0, _ := ret[0].(decimal.Decimal) ret1, _ := ret[1].(error) return ret0, ret1 } -// LatestSequence indicates an expected call of LatestSequence. -func (mr *MockQuerierMockRecorder) LatestSequence(arg0, arg1 any) *gomock.Call { +// LatestPosition indicates an expected call of LatestPosition. +func (mr *MockQuerierMockRecorder) LatestPosition(arg0, arg1 any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestSequence", reflect.TypeOf((*MockQuerier)(nil).LatestSequence), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LatestPosition", reflect.TypeOf((*MockQuerier)(nil).LatestPosition), arg0, arg1) } // MockPusher is a mock of Pusher interface. diff --git a/internal/eventstore/repository/mock/repository.mock.impl.go b/internal/eventstore/repository/mock/repository.mock.impl.go index ced76953cb..313f7ee5e8 100644 --- a/internal/eventstore/repository/mock/repository.mock.impl.go +++ b/internal/eventstore/repository/mock/repository.mock.impl.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" @@ -197,8 +198,8 @@ func (e *mockEvent) Sequence() uint64 { return e.sequence } -func (e *mockEvent) Position() float64 { - return 0 +func (e *mockEvent) Position() decimal.Decimal { + return decimal.Decimal{} } func (e *mockEvent) CreatedAt() time.Time { diff --git a/internal/eventstore/repository/search_query.go b/internal/eventstore/repository/search_query.go index 6ffba31ca8..760f7f616c 100644 --- a/internal/eventstore/repository/search_query.go +++ b/internal/eventstore/repository/search_query.go @@ -3,6 +3,8 @@ package repository import ( "database/sql" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" @@ -57,6 +59,8 @@ const ( // OperationNotIn checks if a stored value does not match one of the passed value list OperationNotIn + OperationGreaterOrEquals + operationCount ) @@ -250,10 +254,10 @@ func instanceIDsFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuer } func positionAfterFilter(builder *eventstore.SearchQueryBuilder, query *SearchQuery) *Filter { - if builder.GetPositionAfter() == 0 { + if builder.GetPositionAtLeast().IsZero() { return nil } - query.Position = NewFilter(FieldPosition, builder.GetPositionAfter(), OperationGreater) + query.Position = NewFilter(FieldPosition, builder.GetPositionAtLeast(), OperationGreaterOrEquals) return query.Position } @@ -295,7 +299,7 @@ func eventDataFilter(query *eventstore.SearchQuery) *Filter { } func eventPositionAfterFilter(query *eventstore.SearchQuery) *Filter { - if pos := query.GetPositionAfter(); pos != 0 { + if pos := query.GetPositionAfter(); !pos.Equal(decimal.Decimal{}) { return NewFilter(FieldPosition, pos, OperationGreater) } return nil diff --git a/internal/eventstore/repository/sql/local_postgres_test.go b/internal/eventstore/repository/sql/local_postgres_test.go index 765da213e3..ae1f7b4831 100644 --- a/internal/eventstore/repository/sql/local_postgres_test.go +++ b/internal/eventstore/repository/sql/local_postgres_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + pgxdecimal "github.com/jackc/pgx-shopspring-decimal" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" "github.com/zitadel/logging" @@ -30,7 +32,11 @@ func TestMain(m *testing.M) { connConfig, err := pgxpool.ParseConfig(config.GetConnectionURL()) logging.OnError(err).Fatal("unable to parse db url") - connConfig.AfterConnect = new_es.RegisterEventstoreTypes + connConfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + pgxdecimal.Register(conn.TypeMap()) + return new_es.RegisterEventstoreTypes(ctx, conn) + } + pool, err := pgxpool.NewWithConfig(context.Background(), connConfig) logging.OnError(err).Fatal("unable to create db pool") diff --git a/internal/eventstore/repository/sql/postgres.go b/internal/eventstore/repository/sql/postgres.go index bc9ad2e029..0dc2210f7b 100644 --- a/internal/eventstore/repository/sql/postgres.go +++ b/internal/eventstore/repository/sql/postgres.go @@ -2,12 +2,12 @@ package sql import ( "context" - "database/sql" "errors" "regexp" "strconv" "github.com/jackc/pgx/v5/pgconn" + "github.com/shopspring/decimal" "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/eventstore" @@ -55,11 +55,11 @@ func (psql *Postgres) FilterToReducer(ctx context.Context, searchQuery *eventsto return err } -// LatestSequence returns the latest sequence found by the search query -func (db *Postgres) LatestSequence(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder) (float64, error) { - var position sql.NullFloat64 +// LatestPosition returns the latest position found by the search query +func (db *Postgres) LatestPosition(ctx context.Context, searchQuery *eventstore.SearchQueryBuilder) (decimal.Decimal, error) { + var position decimal.Decimal err := query(ctx, db, searchQuery, &position, false) - return position.Float64, err + return position, err } // InstanceIDs returns the instance ids found by the search query @@ -126,7 +126,7 @@ func (db *Postgres) eventQuery(useV1 bool) string { " FROM eventstore.events2" } -func (db *Postgres) maxSequenceQuery(useV1 bool) string { +func (db *Postgres) maxPositionQuery(useV1 bool) string { if useV1 { return `SELECT event_sequence FROM eventstore.events` } @@ -207,6 +207,8 @@ func (db *Postgres) operation(operation repository.Operation) string { return "=" case repository.OperationGreater: return ">" + case repository.OperationGreaterOrEquals: + return ">=" case repository.OperationLess: return "<" case repository.OperationJSONContains: diff --git a/internal/eventstore/repository/sql/postgres_test.go b/internal/eventstore/repository/sql/postgres_test.go index 151fdd1b6a..8a9b7bc049 100644 --- a/internal/eventstore/repository/sql/postgres_test.go +++ b/internal/eventstore/repository/sql/postgres_test.go @@ -4,6 +4,8 @@ import ( "database/sql" "testing" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) @@ -312,7 +314,7 @@ func generateEvent(t *testing.T, aggregateID string, opts ...func(*repository.Ev ResourceOwner: sql.NullString{String: "ro", Valid: true}, Typ: "test.created", Version: "v1", - Pos: 42, + Pos: decimal.NewFromInt(42), } for _, opt := range opts { diff --git a/internal/eventstore/repository/sql/query.go b/internal/eventstore/repository/sql/query.go index a545225d9e..8584a82fa0 100644 --- a/internal/eventstore/repository/sql/query.go +++ b/internal/eventstore/repository/sql/query.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" @@ -24,7 +25,7 @@ type querier interface { conditionFormat(repository.Operation) string placeholder(query string) string eventQuery(useV1 bool) string - maxSequenceQuery(useV1 bool) string + maxPositionQuery(useV1 bool) string instanceIDsQuery(useV1 bool) string Client() *database.DB orderByEventSequence(desc, shouldOrderBySequence, useV1 bool) string @@ -68,7 +69,7 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search // instead of using the max function of the database (which doesn't work for postgres) // we select the most recent row - if q.Columns == eventstore.ColumnsMaxSequence { + if q.Columns == eventstore.ColumnsMaxPosition { q.Limit = 1 q.Desc = true } @@ -85,7 +86,7 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search switch q.Columns { case eventstore.ColumnsEvent, - eventstore.ColumnsMaxSequence: + eventstore.ColumnsMaxPosition: query += criteria.orderByEventSequence(q.Desc, shouldOrderBySequence, useV1) } @@ -141,8 +142,8 @@ func query(ctx context.Context, criteria querier, searchQuery *eventstore.Search func prepareColumns(criteria querier, columns eventstore.Columns, useV1 bool) (string, func(s scan, dest interface{}) error) { switch columns { - case eventstore.ColumnsMaxSequence: - return criteria.maxSequenceQuery(useV1), maxSequenceScanner + case eventstore.ColumnsMaxPosition: + return criteria.maxPositionQuery(useV1), maxPositionScanner case eventstore.ColumnsInstanceIDs: return criteria.instanceIDsQuery(useV1), instanceIDsScanner case eventstore.ColumnsEvent: @@ -152,13 +153,15 @@ func prepareColumns(criteria querier, columns eventstore.Columns, useV1 bool) (s } } -func maxSequenceScanner(row scan, dest any) (err error) { - position, ok := dest.(*sql.NullFloat64) +func maxPositionScanner(row scan, dest interface{}) (err error) { + position, ok := dest.(*decimal.Decimal) if !ok { - return zerrors.ThrowInvalidArgumentf(nil, "SQL-NBjA9", "type must be sql.NullInt64 got: %T", dest) + return zerrors.ThrowInvalidArgumentf(nil, "SQL-NBjA9", "type must be pointer to decimal.Decimal got: %T", dest) } - err = row(position) + var res decimal.NullDecimal + err = row(&res) if err == nil || errors.Is(err, sql.ErrNoRows) { + *position = res.Decimal return nil } return zerrors.ThrowInternal(err, "SQL-bN5xg", "something went wrong") @@ -187,7 +190,7 @@ func eventsScanner(useV1 bool) func(scanner scan, dest interface{}) (err error) return zerrors.ThrowInvalidArgumentf(nil, "SQL-4GP6F", "events scanner: invalid type %T", dest) } event := new(repository.Event) - position := new(sql.NullFloat64) + position := new(decimal.NullDecimal) if useV1 { err = scanner( @@ -224,7 +227,7 @@ func eventsScanner(useV1 bool) func(scanner scan, dest interface{}) (err error) logging.New().WithError(err).Warn("unable to scan row") return zerrors.ThrowInternal(err, "SQL-M0dsf", "unable to scan row") } - event.Pos = position.Float64 + event.Pos = position.Decimal return reduce(event) } } diff --git a/internal/eventstore/repository/sql/query_test.go b/internal/eventstore/repository/sql/query_test.go index 3df819be64..0e2425dd07 100644 --- a/internal/eventstore/repository/sql/query_test.go +++ b/internal/eventstore/repository/sql/query_test.go @@ -7,10 +7,12 @@ import ( "reflect" "regexp" "strconv" + "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/database" @@ -111,36 +113,36 @@ func Test_prepareColumns(t *testing.T) { { name: "max column", args: args{ - columns: eventstore.ColumnsMaxSequence, - dest: new(sql.NullFloat64), + columns: eventstore.ColumnsMaxPosition, + dest: new(decimal.Decimal), useV1: true, }, res: res{ query: `SELECT event_sequence FROM eventstore.events`, - expected: sql.NullFloat64{Float64: 43, Valid: true}, + expected: decimal.NewFromInt(42), }, fields: fields{ - dbRow: []interface{}{sql.NullFloat64{Float64: 43, Valid: true}}, + dbRow: []interface{}{decimal.NewNullDecimal(decimal.NewFromInt(42))}, }, }, { name: "max column v2", args: args{ - columns: eventstore.ColumnsMaxSequence, - dest: new(sql.NullFloat64), + columns: eventstore.ColumnsMaxPosition, + dest: new(decimal.Decimal), }, res: res{ query: `SELECT "position" FROM eventstore.events2`, - expected: sql.NullFloat64{Float64: 43, Valid: true}, + expected: decimal.NewFromInt(42), }, fields: fields{ - dbRow: []interface{}{sql.NullFloat64{Float64: 43, Valid: true}}, + dbRow: []interface{}{decimal.NewNullDecimal(decimal.NewFromInt(42))}, }, }, { name: "max sequence wrong dest type", args: args{ - columns: eventstore.ColumnsMaxSequence, + columns: eventstore.ColumnsMaxPosition, dest: new(uint64), }, res: res{ @@ -180,11 +182,11 @@ func Test_prepareColumns(t *testing.T) { res: res{ query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2`, expected: []eventstore.Event{ - &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: 42, Data: nil, Version: "v1"}, + &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.NewFromInt(42), Data: nil, Version: "v1"}, }, }, fields: fields{ - dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), sql.NullFloat64{Float64: 42, Valid: true}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NewNullDecimal(decimal.NewFromInt(42)), sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, }, }, { @@ -199,11 +201,11 @@ func Test_prepareColumns(t *testing.T) { res: res{ query: `SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2`, expected: []eventstore.Event{ - &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: 0, Data: nil, Version: "v1"}, + &repository.Event{AggregateID: "hodor", AggregateType: "user", Seq: 5, Pos: decimal.Decimal{}, Data: nil, Version: "v1"}, }, }, fields: fields{ - dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), sql.NullFloat64{Float64: 0, Valid: false}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, + dbRow: []interface{}{time.Time{}, eventstore.EventType(""), uint64(5), decimal.NullDecimal{}, sql.RawBytes(nil), "", sql.NullString{}, "", eventstore.AggregateType("user"), "hodor", uint8(1)}, }, }, { @@ -901,7 +903,7 @@ func Test_query_events_mocked(t *testing.T) { InstanceID("instanceID"). OrderDesc(). Limit(5). - PositionAfter(123.456). + PositionAtLeast(decimal.NewFromFloat(123.456)). AddQuery(). AggregateTypes("notify"). EventTypes("notify.foo.bar"). @@ -914,8 +916,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery( - regexp.QuoteMeta(`SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" > $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" > $8) ORDER BY event_sequence DESC LIMIT $9`), - []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), 123.456, eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", 123.456, uint64(5)}, + regexp.QuoteMeta(`SELECT creation_date, event_type, event_sequence, event_data, editor_user, resource_owner, instance_id, aggregate_type, aggregate_id, aggregate_version FROM eventstore.events WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" >= $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" >= $8) ORDER BY event_sequence DESC LIMIT $9`), + []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), decimal.NewFromFloat(123.456), eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", decimal.NewFromFloat(123.456), uint64(5)}, ), }, res: res{ @@ -930,7 +932,7 @@ func Test_query_events_mocked(t *testing.T) { InstanceID("instanceID"). OrderDesc(). Limit(5). - PositionAfter(123.456). + PositionAtLeast(decimal.NewFromFloat(123.456)). AddQuery(). AggregateTypes("notify"). EventTypes("notify.foo.bar"). @@ -943,8 +945,8 @@ func Test_query_events_mocked(t *testing.T) { }, fields: fields{ mock: newMockClient(t).expectQuery( - regexp.QuoteMeta(`SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" > $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" > $8) ORDER BY "position" DESC, in_tx_order DESC LIMIT $9`), - []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), 123.456, eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", 123.456, uint64(5)}, + regexp.QuoteMeta(`SELECT created_at, event_type, "sequence", "position", payload, creator, "owner", instance_id, aggregate_type, aggregate_id, revision FROM eventstore.events2 WHERE instance_id = $1 AND aggregate_type = $2 AND event_type = $3 AND "position" >= $4 AND aggregate_id NOT IN (SELECT aggregate_id FROM eventstore.events2 WHERE aggregate_type = $5 AND event_type = ANY($6) AND instance_id = $7 AND "position" >= $8) ORDER BY "position" DESC, in_tx_order DESC LIMIT $9`), + []driver.Value{"instanceID", eventstore.AggregateType("notify"), eventstore.EventType("notify.foo.bar"), decimal.NewFromFloat(123.456), eventstore.AggregateType("notify"), []eventstore.EventType{"notification.failed", "notification.success"}, "instanceID", decimal.NewFromFloat(123.456), uint64(5)}, ), }, res: res{ @@ -988,6 +990,10 @@ func Test_query_events_mocked(t *testing.T) { client.DB.DB = tt.fields.mock.client } + if strings.HasPrefix(tt.name, "aggregate / event type, position and exclusion") { + t.Log("hodor") + } + err := query(context.Background(), client, tt.args.query, tt.args.dest, tt.args.useV1) if (err != nil) != tt.res.wantErr { t.Errorf("query() error = %v, wantErr %v", err, tt.res.wantErr) diff --git a/internal/eventstore/search_query.go b/internal/eventstore/search_query.go index 1596936a36..dc92f5a4de 100644 --- a/internal/eventstore/search_query.go +++ b/internal/eventstore/search_query.go @@ -5,6 +5,8 @@ import ( "database/sql" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -25,7 +27,7 @@ type SearchQueryBuilder struct { tx *sql.Tx lockRows bool lockOption LockOption - positionAfter float64 + positionAtLeast decimal.Decimal awaitOpenTransactions bool creationDateAfter time.Time creationDateBefore time.Time @@ -76,8 +78,8 @@ func (b *SearchQueryBuilder) GetTx() *sql.Tx { return b.tx } -func (b SearchQueryBuilder) GetPositionAfter() float64 { - return b.positionAfter +func (b SearchQueryBuilder) GetPositionAtLeast() decimal.Decimal { + return b.positionAtLeast } func (b SearchQueryBuilder) GetAwaitOpenTransactions() bool { @@ -113,7 +115,7 @@ type SearchQuery struct { aggregateIDs []string eventTypes []EventType eventData map[string]interface{} - positionAfter float64 + positionAfter decimal.Decimal } func (q SearchQuery) GetAggregateTypes() []AggregateType { @@ -132,7 +134,7 @@ func (q SearchQuery) GetEventData() map[string]interface{} { return q.eventData } -func (q SearchQuery) GetPositionAfter() float64 { +func (q SearchQuery) GetPositionAfter() decimal.Decimal { return q.positionAfter } @@ -156,8 +158,8 @@ type Columns int8 const ( //ColumnsEvent represents all fields of an event ColumnsEvent = iota + 1 - // ColumnsMaxSequence represents the latest sequence of the filtered events - ColumnsMaxSequence + // ColumnsMaxPosition represents the latest sequence of the filtered events + ColumnsMaxPosition // ColumnsInstanceIDs represents the instance ids of the filtered events ColumnsInstanceIDs @@ -284,9 +286,9 @@ func (builder *SearchQueryBuilder) EditorUser(id string) *SearchQueryBuilder { return builder } -// PositionAfter filters for events which happened after the specified time -func (builder *SearchQueryBuilder) PositionAfter(position float64) *SearchQueryBuilder { - builder.positionAfter = position +// PositionAtLeast filters for events which happened after the specified time +func (builder *SearchQueryBuilder) PositionAtLeast(position decimal.Decimal) *SearchQueryBuilder { + builder.positionAtLeast = position return builder } @@ -393,7 +395,7 @@ func (query *SearchQuery) EventData(data map[string]interface{}) *SearchQuery { return query } -func (query *SearchQuery) PositionAfter(position float64) *SearchQuery { +func (query *SearchQuery) PositionAfter(position decimal.Decimal) *SearchQuery { query.positionAfter = position return query } diff --git a/internal/eventstore/search_query_test.go b/internal/eventstore/search_query_test.go index b8f570dc0d..3325ee0c4b 100644 --- a/internal/eventstore/search_query_test.go +++ b/internal/eventstore/search_query_test.go @@ -106,10 +106,10 @@ func TestSearchQuerybuilderSetters(t *testing.T) { { name: "set columns", args: args{ - setters: []func(*SearchQueryBuilder) *SearchQueryBuilder{testSetColumns(ColumnsMaxSequence)}, + setters: []func(*SearchQueryBuilder) *SearchQueryBuilder{testSetColumns(ColumnsMaxPosition)}, }, res: &SearchQueryBuilder{ - columns: ColumnsMaxSequence, + columns: ColumnsMaxPosition, }, }, { diff --git a/internal/eventstore/v1/models/event.go b/internal/eventstore/v1/models/event.go index 8c50d64da0..ab2b608872 100644 --- a/internal/eventstore/v1/models/event.go +++ b/internal/eventstore/v1/models/event.go @@ -5,6 +5,8 @@ import ( "reflect" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -20,7 +22,7 @@ var _ eventstore.Event = (*Event)(nil) type Event struct { ID string Seq uint64 - Pos float64 + Pos decimal.Decimal CreationDate time.Time Typ eventstore.EventType PreviousSequence uint64 @@ -80,7 +82,7 @@ func (e *Event) Sequence() uint64 { } // Position implements [eventstore.Event] -func (e *Event) Position() float64 { +func (e *Event) Position() decimal.Decimal { return e.Pos } diff --git a/internal/eventstore/v3/event.go b/internal/eventstore/v3/event.go index 1141a9eacf..c9ea4d2c62 100644 --- a/internal/eventstore/v3/event.go +++ b/internal/eventstore/v3/event.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -42,7 +43,7 @@ type event struct { command *command createdAt time.Time sequence uint64 - position float64 + position decimal.Decimal } // TODO: remove on v3 @@ -152,8 +153,8 @@ func (e *event) Sequence() uint64 { return e.sequence } -// Sequence implements [eventstore.Event] -func (e *event) Position() float64 { +// Position implements [eventstore.Event] +func (e *event) Position() decimal.Decimal { return e.position } diff --git a/internal/notification/projections.go b/internal/notification/projections.go index 9b6b975fa1..7fda08135c 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -71,6 +71,26 @@ func Start(ctx context.Context) { } } +func SetCurrentState(ctx context.Context, es *eventstore.Eventstore) error { + if len(projections) == 0 { + return nil + } + position, err := es.LatestPosition(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition).InstanceID(authz.GetInstance(ctx).InstanceID()).OrderDesc().Limit(1)) + if err != nil { + return err + } + + for i, projection := range projections { + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("set current state of notification projection") + _, err = projection.Trigger(ctx, handler.WithMinPosition(position)) + if err != nil { + return err + } + logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("current state of notification projection set") + } + return nil +} + func ProjectInstance(ctx context.Context) error { for i, projection := range projections { logging.WithFields("name", projection.ProjectionName(), "instance", authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting notification projection") diff --git a/internal/query/access_token.go b/internal/query/access_token.go index 0fc1bbb369..030ddda473 100644 --- a/internal/query/access_token.go +++ b/internal/query/access_token.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/domain" @@ -140,7 +141,7 @@ func (q *Queries) accessTokenByOIDCSessionAndTokenID(ctx context.Context, oidcSe // checkSessionNotTerminatedAfter checks if a [session.TerminateType] event (or user events leading to a session termination) // occurred after a certain time and will return an error if so. -func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID string, position float64, fingerprintID string) (err error) { +func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, userID string, position decimal.Decimal, fingerprintID string) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -165,7 +166,7 @@ func (q *Queries) checkSessionNotTerminatedAfter(ctx context.Context, sessionID, } type sessionTerminatedModel struct { - position float64 + position decimal.Decimal sessionID string userID string fingerPrintID string diff --git a/internal/query/current_state.go b/internal/query/current_state.go index 6fae52713f..d0a5b369bf 100644 --- a/internal/query/current_state.go +++ b/internal/query/current_state.go @@ -10,6 +10,7 @@ import ( "time" sq "github.com/Masterminds/squirrel" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/eventstore" @@ -25,7 +26,7 @@ type Stateful interface { type State struct { LastRun time.Time - Position float64 + Position decimal.Decimal EventCreatedAt time.Time AggregateID string AggregateType eventstore.AggregateType @@ -220,7 +221,7 @@ func prepareLatestState() (sq.SelectBuilder, func(*sql.Row) (*State, error)) { var ( creationDate sql.NullTime lastUpdated sql.NullTime - position sql.NullFloat64 + position decimal.NullDecimal ) err := row.Scan( &creationDate, @@ -233,7 +234,7 @@ func prepareLatestState() (sq.SelectBuilder, func(*sql.Row) (*State, error)) { return &State{ EventCreatedAt: creationDate.Time, LastRun: lastUpdated.Time, - Position: position.Float64, + Position: position.Decimal, }, nil } } @@ -258,7 +259,7 @@ func prepareCurrentStateQuery() (sq.SelectBuilder, func(*sql.Rows) (*CurrentStat var ( lastRun sql.NullTime eventDate sql.NullTime - currentPosition sql.NullFloat64 + currentPosition decimal.NullDecimal aggregateType sql.NullString aggregateID sql.NullString sequence sql.NullInt64 @@ -279,7 +280,7 @@ func prepareCurrentStateQuery() (sq.SelectBuilder, func(*sql.Rows) (*CurrentStat } currentState.State.EventCreatedAt = eventDate.Time currentState.State.LastRun = lastRun.Time - currentState.Position = currentPosition.Float64 + currentState.Position = currentPosition.Decimal currentState.AggregateType = eventstore.AggregateType(aggregateType.String) currentState.AggregateID = aggregateID.String currentState.Sequence = uint64(sequence.Int64) diff --git a/internal/query/current_state_test.go b/internal/query/current_state_test.go index c0895dc439..29761b8cb3 100644 --- a/internal/query/current_state_test.go +++ b/internal/query/current_state_test.go @@ -7,6 +7,8 @@ import ( "fmt" "regexp" "testing" + + "github.com/shopspring/decimal" ) var ( @@ -86,7 +88,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { State: State{ EventCreatedAt: testNow, LastRun: testNow, - Position: 20211108, + Position: decimal.NewFromInt(20211108), AggregateID: "agg-id", AggregateType: "agg-type", Sequence: 20211108, @@ -133,7 +135,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { ProjectionName: "projection-name", State: State{ EventCreatedAt: testNow, - Position: 20211108, + Position: decimal.NewFromInt(20211108), LastRun: testNow, AggregateID: "agg-id", AggregateType: "agg-type", @@ -144,7 +146,7 @@ func Test_CurrentSequencesPrepares(t *testing.T) { ProjectionName: "projection-name2", State: State{ EventCreatedAt: testNow, - Position: 20211108, + Position: decimal.NewFromInt(20211108), LastRun: testNow, AggregateID: "agg-id", AggregateType: "agg-type", diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 07953a27e8..77a28ac79a 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -2,8 +2,10 @@ package projection import ( "context" + "errors" "fmt" + "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" internal_authz "github.com/zitadel/zitadel/internal/api/authz" @@ -212,11 +214,19 @@ func Start(ctx context.Context) { func ProjectInstance(ctx context.Context) error { for i, projection := range projections { logging.WithFields("name", projection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("starting projection") - _, err := projection.Trigger(ctx) - if err != nil { - return err + for { + _, err := projection.Trigger(ctx) + if err == nil { + break + } + var pgErr *pgconn.PgError + errors.As(err, &pgErr) + if pgErr.Code != database.PgUniqueConstraintErrorCode { + return err + } + logging.WithFields("name", projection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID()).WithError(err).Debug("projection failed because of unique constraint, retrying") } - logging.WithFields("name", projection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID()).Info("projection done") + logging.WithFields("name", projection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(projections))).Info("projection done") } return nil } @@ -224,11 +234,19 @@ func ProjectInstance(ctx context.Context) error { func ProjectInstanceFields(ctx context.Context) error { for i, fieldProjection := range fields { logging.WithFields("name", fieldProjection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(fields))).Info("starting fields projection") - err := fieldProjection.Trigger(ctx) - if err != nil { - return err + for { + err := fieldProjection.Trigger(ctx) + if err == nil { + break + } + var pgErr *pgconn.PgError + errors.As(err, &pgErr) + if pgErr.Code != database.PgUniqueConstraintErrorCode { + return err + } + logging.WithFields("name", fieldProjection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID()).WithError(err).Debug("fields projection failed because of unique constraint, retrying") } - logging.WithFields("name", fieldProjection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID()).Info("fields projection done") + logging.WithFields("name", fieldProjection.ProjectionName(), "instance", internal_authz.GetInstance(ctx).InstanceID(), "index", fmt.Sprintf("%d/%d", i, len(fields))).Info("fields projection done") } return nil } @@ -257,6 +275,10 @@ func applyCustomConfig(config handler.Config, customConfig CustomConfig) handler return config } +// we know this is ugly, but we need to have a singleton slice of all projections +// and are only able to initialize it after all projections are created +// as setup and start currently create them individually, we make sure we get the right one +// will be refactored when changing to new id based projections func newFieldsList() { fields = []*handler.FieldHandler{ ProjectGrantFields, diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index c3f24c066e..ebd4ab7c0c 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -280,7 +280,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh return nil, zerrors.ThrowInternal(err, "QUERY-wXnQR", "Errors.Query.SQLStatement") } - latestSequence, err := q.latestState(ctx, userGrantTable) + latestState, err := q.latestState(ctx, userGrantTable) if err != nil { return nil, err } @@ -293,7 +293,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh return nil, err } - grants.State = latestSequence + grants.State = latestState return grants, nil } diff --git a/internal/query/user_membership.go b/internal/query/user_membership.go index cae2b4dae3..cb7588624f 100644 --- a/internal/query/user_membership.go +++ b/internal/query/user_membership.go @@ -143,7 +143,7 @@ func (q *Queries) Memberships(ctx context.Context, queries *MembershipSearchQuer if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-T84X9", "Errors.Query.InvalidRequest") } - latestSequence, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) + latestState, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) if err != nil { return nil, err } @@ -156,7 +156,7 @@ func (q *Queries) Memberships(ctx context.Context, queries *MembershipSearchQuer if err != nil { return nil, err } - memberships.State = latestSequence + memberships.State = latestState return memberships, nil } diff --git a/internal/v2/database/number_filter.go b/internal/v2/database/number_filter.go index ce263ceeee..4853806457 100644 --- a/internal/v2/database/number_filter.go +++ b/internal/v2/database/number_filter.go @@ -3,6 +3,7 @@ package database import ( "time" + "github.com/shopspring/decimal" "github.com/zitadel/logging" "golang.org/x/exp/constraints" ) @@ -94,7 +95,7 @@ func (c numberCompare) String() string { } type number interface { - constraints.Integer | constraints.Float | time.Time + constraints.Integer | constraints.Float | time.Time | decimal.Decimal // TODO: condition must know if it's args are named parameters or not // constraints.Integer | constraints.Float | time.Time | placeholder } diff --git a/internal/v2/eventstore/event_store.go b/internal/v2/eventstore/event_store.go index cc447c5e15..e89786c657 100644 --- a/internal/v2/eventstore/event_store.go +++ b/internal/v2/eventstore/event_store.go @@ -2,6 +2,8 @@ package eventstore import ( "context" + + "github.com/shopspring/decimal" ) func NewEventstore(querier Querier, pusher Pusher) *EventStore { @@ -30,12 +32,12 @@ type healthier interface { } type GlobalPosition struct { - Position float64 + Position decimal.Decimal InPositionOrder uint32 } func (gp GlobalPosition) IsLess(other GlobalPosition) bool { - return gp.Position < other.Position || (gp.Position == other.Position && gp.InPositionOrder < other.InPositionOrder) + return gp.Position.LessThan(other.Position) || (gp.Position.Equal(other.Position) && gp.InPositionOrder < other.InPositionOrder) } type Reducer interface { diff --git a/internal/v2/eventstore/postgres/push_test.go b/internal/v2/eventstore/postgres/push_test.go index bb3254427c..afd5fe8b8e 100644 --- a/internal/v2/eventstore/postgres/push_test.go +++ b/internal/v2/eventstore/postgres/push_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/database/mock" "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/zerrors" @@ -818,7 +820,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, }, ), @@ -899,11 +901,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), @@ -984,11 +986,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), @@ -1044,7 +1046,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, }, ), @@ -1099,7 +1101,7 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, }, ), @@ -1181,11 +1183,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), @@ -1272,11 +1274,11 @@ func Test_push(t *testing.T) { [][]driver.Value{ { time.Now(), - float64(123), + decimal.NewFromFloat(123).String(), }, { time.Now(), - float64(123.1), + decimal.NewFromFloat(123.1).String(), }, }, ), diff --git a/internal/v2/eventstore/postgres/query_test.go b/internal/v2/eventstore/postgres/query_test.go index 56f506ac50..34b73bd820 100644 --- a/internal/v2/eventstore/postgres/query_test.go +++ b/internal/v2/eventstore/postgres/query_test.go @@ -8,6 +8,8 @@ import ( "testing" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/database" "github.com/zitadel/zitadel/internal/v2/database/mock" "github.com/zitadel/zitadel/internal/v2/eventstore" @@ -541,13 +543,13 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - eventstore.PositionGreater(123.4, 0), + eventstore.PositionGreater(decimal.NewFromFloat(123.4), 0), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND position > $2 ORDER BY position, in_tx_order", - args: []any{"i1", 123.4}, + args: []any{"i1", decimal.NewFromFloat(123.4)}, }, }, { @@ -555,18 +557,18 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - // eventstore.PositionGreater(123.4, 0), + // eventstore.PositionGreater(decimal.NewFromFloat(123.4), 0), // eventstore.PositionLess(125.4, 10), eventstore.PositionBetween( - &eventstore.GlobalPosition{Position: 123.4}, - &eventstore.GlobalPosition{Position: 125.4, InPositionOrder: 10}, + &eventstore.GlobalPosition{Position: decimal.NewFromFloat(123.4)}, + &eventstore.GlobalPosition{Position: decimal.NewFromFloat(125.4), InPositionOrder: 10}, ), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order < $3) OR position < $4) AND position > $5 ORDER BY position, in_tx_order", - args: []any{"i1", 125.4, uint32(10), 125.4, 123.4}, + args: []any{"i1", decimal.NewFromFloat(125.4), uint32(10), decimal.NewFromFloat(125.4), decimal.NewFromFloat(123.4)}, // TODO: (adlerhurst) would require some refactoring to reuse existing args // query: " WHERE instance_id = $1 AND position > $2 AND ((position = $3 AND in_tx_order < $4) OR position < $3) ORDER BY position, in_tx_order", // args: []any{"i1", 123.4, 125.4, uint32(10)}, @@ -577,13 +579,13 @@ func Test_writeFilter(t *testing.T) { args: args{ filter: eventstore.NewFilter( eventstore.FilterPagination( - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order > $3) OR position > $4) ORDER BY position, in_tx_order", - args: []any{"i1", 123.4, uint32(12), 123.4}, + args: []any{"i1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4)}, }, }, { @@ -593,13 +595,13 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), ), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND ((position = $2 AND in_tx_order > $3) OR position > $4) ORDER BY position, in_tx_order LIMIT $5 OFFSET $6", - args: []any{"i1", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + args: []any{"i1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, }, }, { @@ -609,14 +611,14 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), ), eventstore.AppendAggregateFilter("user"), ), }, want: wantQuery{ query: " WHERE instance_id = $1 AND aggregate_type = $2 AND ((position = $3 AND in_tx_order > $4) OR position > $5) ORDER BY position, in_tx_order LIMIT $6 OFFSET $7", - args: []any{"i1", "user", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + args: []any{"i1", "user", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, }, }, { @@ -626,7 +628,7 @@ func Test_writeFilter(t *testing.T) { eventstore.FilterPagination( eventstore.Limit(10), eventstore.Offset(3), - eventstore.PositionGreater(123.4, 12), + eventstore.PositionGreater(decimal.NewFromFloat(123.4), 12), ), eventstore.AppendAggregateFilter("user"), eventstore.AppendAggregateFilter( @@ -637,7 +639,7 @@ func Test_writeFilter(t *testing.T) { }, want: wantQuery{ query: " WHERE instance_id = $1 AND (aggregate_type = $2 OR (aggregate_type = $3 AND aggregate_id = $4)) AND ((position = $5 AND in_tx_order > $6) OR position > $7) ORDER BY position, in_tx_order LIMIT $8 OFFSET $9", - args: []any{"i1", "user", "org", "o1", 123.4, uint32(12), 123.4, uint32(10), uint32(3)}, + args: []any{"i1", "user", "org", "o1", decimal.NewFromFloat(123.4), uint32(12), decimal.NewFromFloat(123.4), uint32(10), uint32(3)}, }, }, } @@ -956,7 +958,7 @@ func Test_writeQueryUse_examples(t *testing.T) { ), eventstore.FilterPagination( // used because we need to check for first login and an app which is not console - eventstore.PositionGreater(12, 4), + eventstore.PositionGreater(decimal.NewFromInt(12), 4), ), ), eventstore.NewFilter( @@ -1065,9 +1067,9 @@ func Test_writeQueryUse_examples(t *testing.T) { "instance", "user", "user.token.added", - float64(12), + decimal.NewFromInt(12), uint32(4), - float64(12), + decimal.NewFromInt(12), "instance", "instance", []string{"instance.idp.config.added", "instance.idp.oauth.added", "instance.idp.oidc.added", "instance.idp.jwt.added", "instance.idp.azure.added", "instance.idp.github.added", "instance.idp.github.enterprise.added", "instance.idp.gitlab.added", "instance.idp.gitlab.selfhosted.added", "instance.idp.google.added", "instance.idp.ldap.added", "instance.idp.config.apple.added", "instance.idp.saml.added"}, @@ -1201,7 +1203,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), nil, "gigi", @@ -1235,7 +1237,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), []byte(`{"name": "gigi"}`), "gigi", @@ -1269,7 +1271,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), nil, "gigi", @@ -1283,7 +1285,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(24), - float64(124), + decimal.NewFromInt(124).String(), uint32(0), []byte(`{"name": "gigi"}`), "gigi", @@ -1317,7 +1319,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(23), - float64(123), + decimal.NewFromInt(123).String(), uint32(0), nil, "gigi", @@ -1331,7 +1333,7 @@ func Test_executeQuery(t *testing.T) { time.Now(), "event.type", uint32(24), - float64(124), + decimal.NewFromInt(124).String(), uint32(0), []byte(`{"name": "gigi"}`), "gigi", diff --git a/internal/v2/eventstore/query.go b/internal/v2/eventstore/query.go index c9b3cecd37..f7a30a2139 100644 --- a/internal/v2/eventstore/query.go +++ b/internal/v2/eventstore/query.go @@ -7,6 +7,8 @@ import ( "slices" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/database" ) @@ -723,7 +725,7 @@ func (pc *PositionCondition) Min() *GlobalPosition { // PositionGreater prepares the condition as follows // if inPositionOrder is set: position = AND in_tx_order > OR or position > // if inPositionOrder is NOT set: position > -func PositionGreater(position float64, inPositionOrder uint32) paginationOpt { +func PositionGreater(position decimal.Decimal, inPositionOrder uint32) paginationOpt { return func(p *Pagination) { p.ensurePosition() p.position.min = &GlobalPosition{ @@ -743,7 +745,7 @@ func GlobalPositionGreater(position *GlobalPosition) paginationOpt { // PositionLess prepares the condition as follows // if inPositionOrder is set: position = AND in_tx_order > OR or position > // if inPositionOrder is NOT set: position > -func PositionLess(position float64, inPositionOrder uint32) paginationOpt { +func PositionLess(position decimal.Decimal, inPositionOrder uint32) paginationOpt { return func(p *Pagination) { p.ensurePosition() p.position.max = &GlobalPosition{ diff --git a/internal/v2/eventstore/query_test.go b/internal/v2/eventstore/query_test.go index 00c08914c1..0f313e9560 100644 --- a/internal/v2/eventstore/query_test.go +++ b/internal/v2/eventstore/query_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/database" ) @@ -74,13 +76,13 @@ func TestPaginationOpt(t *testing.T) { name: "global position greater", args: args{ opts: []paginationOpt{ - GlobalPositionGreater(&GlobalPosition{Position: 10}), + GlobalPositionGreater(&GlobalPosition{Position: decimal.NewFromInt(10)}), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 0, }, }, @@ -90,13 +92,13 @@ func TestPaginationOpt(t *testing.T) { name: "position greater", args: args{ opts: []paginationOpt{ - PositionGreater(10, 0), + PositionGreater(decimal.NewFromInt(10), 0), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 0, }, }, @@ -107,13 +109,13 @@ func TestPaginationOpt(t *testing.T) { name: "position less", args: args{ opts: []paginationOpt{ - PositionLess(10, 12), + PositionLess(decimal.NewFromInt(10), 12), }, }, want: &Pagination{ position: &PositionCondition{ max: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 12, }, }, @@ -123,13 +125,13 @@ func TestPaginationOpt(t *testing.T) { name: "global position less", args: args{ opts: []paginationOpt{ - GlobalPositionLess(&GlobalPosition{Position: 12, InPositionOrder: 24}), + GlobalPositionLess(&GlobalPosition{Position: decimal.NewFromInt(12), InPositionOrder: 24}), }, }, want: &Pagination{ position: &PositionCondition{ max: &GlobalPosition{ - Position: 12, + Position: decimal.NewFromInt(12), InPositionOrder: 24, }, }, @@ -140,19 +142,19 @@ func TestPaginationOpt(t *testing.T) { args: args{ opts: []paginationOpt{ PositionBetween( - &GlobalPosition{10, 12}, - &GlobalPosition{20, 0}, + &GlobalPosition{decimal.NewFromInt(10), 12}, + &GlobalPosition{decimal.NewFromInt(20), 0}, ), }, }, want: &Pagination{ position: &PositionCondition{ min: &GlobalPosition{ - Position: 10, + Position: decimal.NewFromInt(10), InPositionOrder: 12, }, max: &GlobalPosition{ - Position: 20, + Position: decimal.NewFromInt(20), InPositionOrder: 0, }, }, diff --git a/internal/v2/readmodel/last_successful_mirror.go b/internal/v2/readmodel/last_successful_mirror.go index 80b436b896..ca7815b2a8 100644 --- a/internal/v2/readmodel/last_successful_mirror.go +++ b/internal/v2/readmodel/last_successful_mirror.go @@ -1,6 +1,8 @@ package readmodel import ( + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/v2/system" "github.com/zitadel/zitadel/internal/v2/system/mirror" @@ -8,7 +10,7 @@ import ( type LastSuccessfulMirror struct { ID string - Position float64 + Position decimal.Decimal source string } @@ -34,6 +36,7 @@ func (p *LastSuccessfulMirror) Filter() *eventstore.Filter { ), eventstore.FilterPagination( eventstore.Descending(), + eventstore.Limit(1), ), ) } @@ -53,7 +56,7 @@ func (h *LastSuccessfulMirror) Reduce(events ...*eventstore.StorageEvent) (err e func (h *LastSuccessfulMirror) reduceSucceeded(event *eventstore.StorageEvent) error { // if position is set we skip all older events - if h.Position > 0 { + if h.Position.GreaterThan(decimal.NewFromInt(0)) { return nil } diff --git a/internal/v2/system/mirror/succeeded.go b/internal/v2/system/mirror/succeeded.go index 6d0fba2c25..34d74f184f 100644 --- a/internal/v2/system/mirror/succeeded.go +++ b/internal/v2/system/mirror/succeeded.go @@ -1,6 +1,8 @@ package mirror import ( + "github.com/shopspring/decimal" + "github.com/zitadel/zitadel/internal/v2/eventstore" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -9,7 +11,7 @@ type succeededPayload struct { // Source is the name of the database data are mirrored from Source string `json:"source"` // Position until data will be mirrored - Position float64 `json:"position"` + Position decimal.Decimal `json:"position"` } const SucceededType = eventTypePrefix + "succeeded" @@ -38,7 +40,7 @@ func SucceededEventFromStorage(event *eventstore.StorageEvent) (e *SucceededEven }, nil } -func NewSucceededCommand(source string, position float64) *eventstore.Command { +func NewSucceededCommand(source string, position decimal.Decimal) *eventstore.Command { return &eventstore.Command{ Action: eventstore.Action[any]{ Creator: Creator, From 5e87fafadf1ecafdefc934e45eaa94055773fbb2 Mon Sep 17 00:00:00 2001 From: Maximilian Date: Thu, 29 May 2025 20:23:43 +0200 Subject: [PATCH 072/123] docs: fix broken link (#9988) # Which Problems Are Solved Broken links on the default settings page. # How the Problems Are Solved Fixed the reference # Additional Changes # Additional Context --- docs/docs/guides/manage/console/default-settings.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/guides/manage/console/default-settings.mdx b/docs/docs/guides/manage/console/default-settings.mdx index e8e36956a1..f255d15d93 100644 --- a/docs/docs/guides/manage/console/default-settings.mdx +++ b/docs/docs/guides/manage/console/default-settings.mdx @@ -17,7 +17,7 @@ When you configure your default settings, you can set the following: - **Organizations**: A list of your organizations - [**Features**](#features): Feature Settings let you try out new features before they become generally available. You can also disable features you are not interested in. -- [**Notification settings**](#notification-providers-and-smtp): Setup Notification and Email Server settings for initialization-, verification- and other mails. Setup Twilio as SMS notification provider. +- [**Notification settings**](#notification-settings): Setup Notification and Email Server settings for initialization-, verification- and other mails. Setup Twilio as SMS notification provider. - [**Login Behavior and Access**](#login-behavior-and-access): Multifactor Authentication Options and Enforcement, Define whether Passwordless authentication methods are allowed or not, Set Login Lifetimes and advanced behavour for the login interface. - [**Identity Providers**](#identity-providers): Define IDPs which are available for all organizations - [**Password Complexity**](#password-complexity): Requirements for Passwords ex. Symbols, Numbers, min length and more. From 93a92446bfa7e7a8b38aa32125e1020ae08c64f1 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Fri, 30 May 2025 01:15:28 -0700 Subject: [PATCH 073/123] chore: update docusaurus to 3.8.0 (#9974) > [!IMPORTANT] > We need to change the ENV `VERCEL_FORCE_NO_BUILD_CACHE` to `0` which is currently `1` to enable the cache on all deployments This pull request includes several updates to the documentation and benchmarking components, focusing on improving performance, error handling, and compatibility with newer versions of Docusaurus. The key changes include the removal of outdated configurations, updates to dependencies, and enhancements to the `BenchmarkChart` component for better error handling and data validation. ### Documentation and Configuration Updates: * **Removed outdated Babel and Webpack configurations**: The `babel.config.js` file was deleted, and the Webpack configuration was removed from `docusaurus.config.js` to align with the latest Docusaurus setup. [[1]](diffhunk://#diff-2ed4f5b03d34a87ef641e9e36af4a98a1c0ddaf74d07ce93665957be69b7b09aL1-L4) [[2]](diffhunk://#diff-28742c737e523f302e6de471b7fc27284dc8cf720be639e6afe4c17a550cd654L204-L225) * **Added experimental features in Docusaurus**: Introduced a `future` section in `docusaurus.config.js` to enable experimental features like `swcJsLoader`, `rspackBundler`, and `lightningCssMinimizer`, while disabling problematic settings due to known issues. ### Dependency Updates: * **Upgraded Docusaurus and related packages**: Updated dependencies in `package.json` to use Docusaurus version `^3.8.0` and newer versions of associated plugins and themes for improved performance and compatibility. [[1]](diffhunk://#diff-adfa337ce44dc2902621da20152a048dac41878cf3716dfc4cc56d03aa212a56L25-R39) [[2]](diffhunk://#diff-adfa337ce44dc2902621da20152a048dac41878cf3716dfc4cc56d03aa212a56L66-R67) ### Component Enhancements: * **Improved `BenchmarkChart` error handling**: Refactored the `BenchmarkChart` component to validate input data, handle errors gracefully, and provide meaningful fallback messages when data is missing or invalid. [[1]](diffhunk://#diff-ce9fccf51f6b863dd58a39f361a9cf980b10357bccc7381f928788483b30cb0eL4-R21) [[2]](diffhunk://#diff-ce9fccf51f6b863dd58a39f361a9cf980b10357bccc7381f928788483b30cb0eR72-R76) * **Fixed edge cases in chart rendering**: Addressed issues like invalid timestamps, undefined `p99` values, and empty data sets to ensure robust chart generation. [[1]](diffhunk://#diff-ce9fccf51f6b863dd58a39f361a9cf980b10357bccc7381f928788483b30cb0eL19-L29) [[2]](diffhunk://#diff-ce9fccf51f6b863dd58a39f361a9cf980b10357bccc7381f928788483b30cb0eL38-R61) ### Documentation Benchmark Updates: * **Simplified imports in benchmark files**: Replaced the use of `raw-loader` with direct imports for benchmark data in multiple `.mdx` files to streamline the documentation setup. [[1]](diffhunk://#diff-a9710709396e5ff6756aedf89dfcbd62aeea15368ba33bf3932ebf33046a29e8L66-R66) [[2]](diffhunk://#diff-0a9b6103c97c58792450bfd2d337bbb8a6b72df2ae326cc56ebc96e01c0acd6bL35-R35) [[3]](diffhunk://#diff-38f45388e065c57f1282a43bb319354da3c218e96d95ca20f4d11709f48491b8L36-R36) [[4]](diffhunk://#diff-b8e792ebe42fcb16a493e35d23b58a91c2117d949953487e70f379c64e5cb7c0L36-R36) [[5]](diffhunk://#diff-3778acfa893504004008b162fa95f21f1c7c40dcf1868bbbaaa504ac5d51901aL38-R38) --- docs/babel.config.js | 4 - docs/docs/apis/benchmarks/_template.mdx | 2 +- .../machine_jwt_profile_grant/index.mdx | 2 +- .../machine_jwt_profile_grant/index.mdx | 2 +- .../machine_jwt_profile_grant/index.mdx | 2 +- .../benchmarks/v2.70.0/oidc_session/index.mdx | 2 +- docs/docusaurus.config.js | 35 +- docs/package.json | 30 +- docs/src/components/benchmark_chart.jsx | 32 +- docs/yarn.lock | 5297 ++++++++++++----- 10 files changed, 3794 insertions(+), 1614 deletions(-) delete mode 100644 docs/babel.config.js diff --git a/docs/babel.config.js b/docs/babel.config.js deleted file mode 100644 index 279a0ff91c..0000000000 --- a/docs/babel.config.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - presets: [require.resolve("@docusaurus/core/lib/babel/preset")], - compact: auto -}; diff --git a/docs/docs/apis/benchmarks/_template.mdx b/docs/docs/apis/benchmarks/_template.mdx index f015d20768..578ebcd842 100644 --- a/docs/docs/apis/benchmarks/_template.mdx +++ b/docs/docs/apis/benchmarks/_template.mdx @@ -63,7 +63,7 @@ TODO: describe the outcome of the test? ## Endpoint latencies -import OutputSource from "!!raw-loader!./output.json"; +import OutputSource from "./output.json"; import { BenchmarkChart } from '/src/components/benchmark_chart'; diff --git a/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx b/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx index 4c2809feb4..a8c10780ad 100644 --- a/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx +++ b/docs/docs/apis/benchmarks/v2.65.0/machine_jwt_profile_grant/index.mdx @@ -32,7 +32,7 @@ Tests are halted after this test run because of too many [client read events](ht ## /token endpoint latencies -import OutputSource from "!!raw-loader!./output.json"; +import OutputSource from "./output.json"; import { BenchmarkChart } from '/src/components/benchmark_chart'; diff --git a/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx b/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx index 881e7a38ee..6ab40eb4d4 100644 --- a/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx +++ b/docs/docs/apis/benchmarks/v2.66.0/machine_jwt_profile_grant/index.mdx @@ -33,7 +33,7 @@ The tests showed heavy database load by time by the first two database queries. ## Endpoint latencies -import OutputSource from "!!raw-loader!./output.json"; +import OutputSource from "./output.json"; import { BenchmarkChart } from '/src/components/benchmark_chart'; diff --git a/docs/docs/apis/benchmarks/v2.70.0/machine_jwt_profile_grant/index.mdx b/docs/docs/apis/benchmarks/v2.70.0/machine_jwt_profile_grant/index.mdx index fa8c84bc7e..d4fd2708a8 100644 --- a/docs/docs/apis/benchmarks/v2.70.0/machine_jwt_profile_grant/index.mdx +++ b/docs/docs/apis/benchmarks/v2.70.0/machine_jwt_profile_grant/index.mdx @@ -33,7 +33,7 @@ The performance goals of [this issue](https://github.com/zitadel/zitadel/issues/ ## Endpoint latencies -import OutputSource from "!!raw-loader!./output.json"; +import OutputSource from "./output.json"; import { BenchmarkChart } from '/src/components/benchmark_chart'; diff --git a/docs/docs/apis/benchmarks/v2.70.0/oidc_session/index.mdx b/docs/docs/apis/benchmarks/v2.70.0/oidc_session/index.mdx index 4615413d2b..94fd83f119 100644 --- a/docs/docs/apis/benchmarks/v2.70.0/oidc_session/index.mdx +++ b/docs/docs/apis/benchmarks/v2.70.0/oidc_session/index.mdx @@ -35,7 +35,7 @@ The tests showed that querying the user takes too much time because Zitadel ensu ## Endpoint latencies -import OutputSource from "!!raw-loader!./output.json"; +import OutputSource from "./output.json"; import { BenchmarkChart } from '/src/components/benchmark_chart'; diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 22df468475..c161d38d9f 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -201,28 +201,6 @@ module.exports = { runmeLinkLabel: 'Checkout via Runme' }, }, - webpack: { - jsLoader: (isServer) => ({ - loader: require.resolve('swc-loader'), - options: { - jsc: { - parser: { - syntax: 'typescript', - tsx: true, - }, - transform: { - react: { - runtime: 'automatic', - }, - }, - target: 'es2017', - }, - module: { - type: isServer ? 'commonjs' : 'es6', - }, - }, - }), - }, presets: [ [ "classic", @@ -397,4 +375,17 @@ module.exports = { }, ], themes: [ "docusaurus-theme-github-codeblock", "docusaurus-theme-openapi-docs"], + future: { + v4: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 + experimental_faster: { + swcJsLoader: false, // Disabled because of memory usage > 8GB which is a problem on vercel default runners + swcJsMinimizer: true, + swcHtmlMinimizer : true, + lightningCssMinimizer: true, + mdxCrossCompilerCache: true, + ssgWorkerThreads: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 + rspackBundler: true, + rspackPersistentCache: true, + }, + }, }; diff --git a/docs/package.json b/docs/package.json index f9636418dd..014a8ec0ca 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,7 +6,7 @@ "docusaurus": "docusaurus", "start": "docusaurus start", "start:api": "yarn run generate && docusaurus start", - "build": "yarn run generate && NODE_OPTIONS=--max-old-space-size=8192 docusaurus build", + "build": "yarn run generate && docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", @@ -22,33 +22,27 @@ }, "dependencies": { "@bufbuild/buf": "^1.14.0", - "@docusaurus/core": "3.4.0", - "@docusaurus/preset-classic": "3.4.0", - "@docusaurus/theme-mermaid": "3.4.0", - "@docusaurus/theme-search-algolia": "3.4.0", + "@docusaurus/core": "^3.8.0", + "@docusaurus/faster": "^3.8.0", + "@docusaurus/preset-classic": "^3.8.0", + "@docusaurus/theme-mermaid": "^3.8.0", + "@docusaurus/theme-search-algolia": "^3.8.0", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", - "@mdx-js/react": "^3.0.0", - "@swc/core": "^1.3.74", "autoprefixer": "^10.4.13", "clsx": "^1.2.1", - "docusaurus-plugin-image-zoom": "^1.0.1", - "docusaurus-plugin-openapi-docs": "3.0.1", + "docusaurus-plugin-image-zoom": "^3.0.1", + "docusaurus-plugin-openapi-docs": "4.4.0", "docusaurus-theme-github-codeblock": "^2.0.2", - "docusaurus-theme-openapi-docs": "3.0.1", + "docusaurus-theme-openapi-docs": "4.4.0", "mdx-mermaid": "^2.0.0", - "mermaid": "^10.9.1", "postcss": "^8.4.31", - "prism-react-renderer": "^2.1.0", "raw-loader": "^4.0.2", "react": "^18.2.0", "react-copy-to-clipboard": "^5.1.0", "react-dom": "^18.2.0", "react-google-charts": "^5.2.1", - "react-player": "^2.15.1", - "sitemap": "7.1.1", - "swc-loader": "^0.2.3", - "wait-on": "6.0.1" + "react-player": "^2.15.1" }, "browserslist": { "production": [ @@ -63,8 +57,8 @@ ] }, "devDependencies": { - "@docusaurus/module-type-aliases": "3.4.0", - "@docusaurus/types": "3.4.0", + "@docusaurus/module-type-aliases": "^3.8.0", + "@docusaurus/types": "^3.8.0", "tailwindcss": "^3.2.4" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/docs/src/components/benchmark_chart.jsx b/docs/src/components/benchmark_chart.jsx index 4f0d4bc61c..cf93a842ef 100644 --- a/docs/src/components/benchmark_chart.jsx +++ b/docs/src/components/benchmark_chart.jsx @@ -1,11 +1,24 @@ import React from "react"; import Chart from "react-google-charts"; -export function BenchmarkChart(testResults=[], height='500px') { +export function BenchmarkChart({ testResults = [], height = '500px' } = {}) { + if (!Array.isArray(testResults)) { + console.error("BenchmarkChart: testResults is not an array. Received:", testResults); + return

Error: Benchmark data is not available or in the wrong format.

; + } + + if (testResults.length === 0) { + return

No benchmark data to display.

; + } + const dataPerMetric = new Map(); let maxVValue = 0; - JSON.parse(testResults.testResults).forEach((result) => { + testResults.forEach((result) => { + if (!result || typeof result.metric_name === 'undefined') { + console.warn("BenchmarkChart: Skipping invalid result item:", result); + return; + } if (!dataPerMetric.has(result.metric_name)) { dataPerMetric.set(result.metric_name, [ [ @@ -16,17 +29,16 @@ export function BenchmarkChart(testResults=[], height='500px') { ], ]); } - if (result.p99 > maxVValue) { + if (result.p99 !== undefined && result.p99 > maxVValue) { maxVValue = result.p99; } dataPerMetric.get(result.metric_name).push([ - new Date(result.timestamp), + result.timestamp ? new Date(result.timestamp) : null, result.p50, result.p95, result.p99, ]); }); - const options = { legend: { position: 'bottom' }, focusTarget: 'category', @@ -35,17 +47,18 @@ export function BenchmarkChart(testResults=[], height='500px') { }, vAxis: { title: 'latency (ms)', - maxValue: maxVValue, + maxValue: maxVValue > 0 ? maxVValue : undefined, }, title: '' }; const charts = []; dataPerMetric.forEach((data, metric) => { - const opt = Object.create(options); + const opt = { ...options }; opt.title = metric; charts.push( No chart data could be generated.

; + } - return (charts); + return <>{charts}; } \ No newline at end of file diff --git a/docs/yarn.lock b/docs/yarn.lock index ad31e03b5e..70f2de1f05 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2,158 +2,153 @@ # yarn lockfile v1 -"@algolia/autocomplete-core@1.9.3": - version "1.9.3" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.9.3.tgz#1d56482a768c33aae0868c8533049e02e8961be7" - integrity sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw== +"@algolia/autocomplete-core@1.17.9": + version "1.17.9" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.17.9.tgz#83374c47dc72482aa45d6b953e89377047f0dcdc" + integrity sha512-O7BxrpLDPJWWHv/DLA9DRFWs+iY1uOJZkqUwjS5HSZAGcl0hIVCQ97LTLewiZmZ402JYUrun+8NqFP+hCknlbQ== dependencies: - "@algolia/autocomplete-plugin-algolia-insights" "1.9.3" - "@algolia/autocomplete-shared" "1.9.3" + "@algolia/autocomplete-plugin-algolia-insights" "1.17.9" + "@algolia/autocomplete-shared" "1.17.9" -"@algolia/autocomplete-plugin-algolia-insights@1.9.3": - version "1.9.3" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.9.3.tgz#9b7f8641052c8ead6d66c1623d444cbe19dde587" - integrity sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg== +"@algolia/autocomplete-plugin-algolia-insights@1.17.9": + version "1.17.9" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.9.tgz#74c86024d09d09e8bfa3dd90b844b77d9f9947b6" + integrity sha512-u1fEHkCbWF92DBeB/KHeMacsjsoI0wFhjZtlCq2ddZbAehshbZST6Hs0Avkc0s+4UyBGbMDnSuXHLuvRWK5iDQ== dependencies: - "@algolia/autocomplete-shared" "1.9.3" + "@algolia/autocomplete-shared" "1.17.9" -"@algolia/autocomplete-preset-algolia@1.9.3": - version "1.9.3" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.9.3.tgz#64cca4a4304cfcad2cf730e83067e0c1b2f485da" - integrity sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA== +"@algolia/autocomplete-preset-algolia@1.17.9": + version "1.17.9" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.9.tgz#911f3250544eb8ea4096fcfb268f156b085321b5" + integrity sha512-Na1OuceSJeg8j7ZWn5ssMu/Ax3amtOwk76u4h5J4eK2Nx2KB5qt0Z4cOapCsxot9VcEN11ADV5aUSlQF4RhGjQ== dependencies: - "@algolia/autocomplete-shared" "1.9.3" + "@algolia/autocomplete-shared" "1.17.9" -"@algolia/autocomplete-shared@1.9.3": - version "1.9.3" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.9.3.tgz#2e22e830d36f0a9cf2c0ccd3c7f6d59435b77dfa" - integrity sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ== +"@algolia/autocomplete-shared@1.17.9": + version "1.17.9" + resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.9.tgz#5f38868f7cb1d54b014b17a10fc4f7e79d427fa8" + integrity sha512-iDf05JDQ7I0b7JEA/9IektxN/80a2MZ1ToohfmNS3rfeuQnIKI3IJlIafD0xu4StbtQTghx9T3Maa97ytkXenQ== -"@algolia/cache-browser-local-storage@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.3.tgz#0cc26b96085e1115dac5fcb9d826651ba57faabc" - integrity sha512-vRHXYCpPlTDE7i6UOy2xE03zHF2C8MEFjPN2v7fRbqVpcOvAUQK81x3Kc21xyb5aSIpYCjWCZbYZuz8Glyzyyg== +"@algolia/client-abtesting@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.25.0.tgz#012204f1614e1a71366fb1e117c8f195186ff081" + integrity sha512-1pfQulNUYNf1Tk/svbfjfkLBS36zsuph6m+B6gDkPEivFmso/XnRgwDvjAx80WNtiHnmeNjIXdF7Gos8+OLHqQ== dependencies: - "@algolia/cache-common" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/cache-common@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.23.3.tgz#3bec79092d512a96c9bfbdeec7cff4ad36367166" - integrity sha512-h9XcNI6lxYStaw32pHpB1TMm0RuxphF+Ik4o7tcQiodEdpKK+wKufY6QXtba7t3k8eseirEMVB83uFFF3Nu54A== - -"@algolia/cache-in-memory@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/cache-in-memory/-/cache-in-memory-4.23.3.tgz#3945f87cd21ffa2bec23890c85305b6b11192423" - integrity sha512-yvpbuUXg/+0rbcagxNT7un0eo3czx2Uf0y4eiR4z4SD7SiptwYTpbuS0IHxcLHG3lq22ukx1T6Kjtk/rT+mqNg== +"@algolia/client-analytics@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.25.0.tgz#eba015bfafb3dbb82712c9160a00717a5974ff71" + integrity sha512-AFbG6VDJX/o2vDd9hqncj1B6B4Tulk61mY0pzTtzKClyTDlNP0xaUiEKhl6E7KO9I/x0FJF5tDCm0Hn6v5x18A== dependencies: - "@algolia/cache-common" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/client-account@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/client-account/-/client-account-4.23.3.tgz#8751bbf636e6741c95e7c778488dee3ee430ac6f" - integrity sha512-hpa6S5d7iQmretHHF40QGq6hz0anWEHGlULcTIT9tbUssWUriN9AUXIFQ8Ei4w9azD0hc1rUok9/DeQQobhQMA== - dependencies: - "@algolia/client-common" "4.23.3" - "@algolia/client-search" "4.23.3" - "@algolia/transporter" "4.23.3" +"@algolia/client-common@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.25.0.tgz#2def8947efe849266057d92f67d1b8d83de0c005" + integrity sha512-il1zS/+Rc6la6RaCdSZ2YbJnkQC6W1wiBO8+SH+DE6CPMWBU6iDVzH0sCKSAtMWl9WBxoN6MhNjGBnCv9Yy2bA== -"@algolia/client-analytics@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-4.23.3.tgz#f88710885278fe6fb6964384af59004a5a6f161d" - integrity sha512-LBsEARGS9cj8VkTAVEZphjxTjMVCci+zIIiRhpFun9jGDUlS1XmhCW7CTrnaWeIuCQS/2iPyRqSy1nXPjcBLRA== +"@algolia/client-insights@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.25.0.tgz#b87df8614b96c4cc9c9aa7765cce07fa70864fa8" + integrity sha512-blbjrUH1siZNfyCGeq0iLQu00w3a4fBXm0WRIM0V8alcAPo7rWjLbMJMrfBtzL9X5ic6wgxVpDADXduGtdrnkw== dependencies: - "@algolia/client-common" "4.23.3" - "@algolia/client-search" "4.23.3" - "@algolia/requester-common" "4.23.3" - "@algolia/transporter" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/client-common@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.23.3.tgz#891116aa0db75055a7ecc107649f7f0965774704" - integrity sha512-l6EiPxdAlg8CYhroqS5ybfIczsGUIAC47slLPOMDeKSVXYG1n0qGiz4RjAHLw2aD0xzh2EXZ7aRguPfz7UKDKw== +"@algolia/client-personalization@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.25.0.tgz#74b041f0e7d91e1009c131c8d716c34e4d45c30f" + integrity sha512-aywoEuu1NxChBcHZ1pWaat0Plw7A8jDMwjgRJ00Mcl7wGlwuPt5dJ/LTNcg3McsEUbs2MBNmw0ignXBw9Tbgow== dependencies: - "@algolia/requester-common" "4.23.3" - "@algolia/transporter" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/client-personalization@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-4.23.3.tgz#35fa8e5699b0295fbc400a8eb211dc711e5909db" - integrity sha512-3E3yF3Ocr1tB/xOZiuC3doHQBQ2zu2MPTYZ0d4lpfWads2WTKG7ZzmGnsHmm63RflvDeLK/UVx7j2b3QuwKQ2g== +"@algolia/client-query-suggestions@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.25.0.tgz#e92d935d9e2994f790d43c64d3518d81070a3888" + integrity sha512-a/W2z6XWKjKjIW1QQQV8PTTj1TXtaKx79uR3NGBdBdGvVdt24KzGAaN7sCr5oP8DW4D3cJt44wp2OY/fZcPAVA== dependencies: - "@algolia/client-common" "4.23.3" - "@algolia/requester-common" "4.23.3" - "@algolia/transporter" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/client-search@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.23.3.tgz#a3486e6af13a231ec4ab43a915a1f318787b937f" - integrity sha512-P4VAKFHqU0wx9O+q29Q8YVuaowaZ5EM77rxfmGnkHUJggh28useXQdopokgwMeYw2XUht49WX5RcTQ40rZIabw== +"@algolia/client-search@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.25.0.tgz#dc38ca1015f2f4c9f5053a4517f96fb28a2117f8" + integrity sha512-9rUYcMIBOrCtYiLX49djyzxqdK9Dya/6Z/8sebPn94BekT+KLOpaZCuc6s0Fpfq7nx5J6YY5LIVFQrtioK9u0g== dependencies: - "@algolia/client-common" "4.23.3" - "@algolia/requester-common" "4.23.3" - "@algolia/transporter" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" "@algolia/events@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@algolia/events/-/events-4.0.1.tgz#fd39e7477e7bc703d7f893b556f676c032af3950" integrity sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ== -"@algolia/logger-common@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.23.3.tgz#35c6d833cbf41e853a4f36ba37c6e5864920bfe9" - integrity sha512-y9kBtmJwiZ9ZZ+1Ek66P0M68mHQzKRxkW5kAAXYN/rdzgDN0d2COsViEFufxJ0pb45K4FRcfC7+33YB4BLrZ+g== - -"@algolia/logger-console@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/logger-console/-/logger-console-4.23.3.tgz#30f916781826c4db5f51fcd9a8a264a06e136985" - integrity sha512-8xoiseoWDKuCVnWP8jHthgaeobDLolh00KJAdMe9XPrWPuf1by732jSpgy2BlsLTaT9m32pHI8CRfrOqQzHv3A== +"@algolia/ingestion@1.25.0": + version "1.25.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.25.0.tgz#4d13c56dda0a05c7bacb0e3ef5866292dfd86ed5" + integrity sha512-jJeH/Hk+k17Vkokf02lkfYE4A+EJX+UgnMhTLR/Mb+d1ya5WhE+po8p5a/Nxb6lo9OLCRl6w3Hmk1TX1e9gVbQ== dependencies: - "@algolia/logger-common" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/recommend@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-4.23.3.tgz#53d4f194d22d9c72dc05f3f7514c5878f87c5890" - integrity sha512-9fK4nXZF0bFkdcLBRDexsnGzVmu4TSYZqxdpgBW2tEyfuSSY54D4qSRkLmNkrrz4YFvdh2GM1gA8vSsnZPR73w== +"@algolia/monitoring@1.25.0": + version "1.25.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.25.0.tgz#d59360cfe556338519d05a9d8107147e9dbcb020" + integrity sha512-Ls3i1AehJ0C6xaHe7kK9vPmzImOn5zBg7Kzj8tRYIcmCWVyuuFwCIsbuIIz/qzUf1FPSWmw0TZrGeTumk2fqXg== dependencies: - "@algolia/cache-browser-local-storage" "4.23.3" - "@algolia/cache-common" "4.23.3" - "@algolia/cache-in-memory" "4.23.3" - "@algolia/client-common" "4.23.3" - "@algolia/client-search" "4.23.3" - "@algolia/logger-common" "4.23.3" - "@algolia/logger-console" "4.23.3" - "@algolia/requester-browser-xhr" "4.23.3" - "@algolia/requester-common" "4.23.3" - "@algolia/requester-node-http" "4.23.3" - "@algolia/transporter" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/requester-browser-xhr@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.3.tgz#9e47e76f60d540acc8b27b4ebc7a80d1b41938b9" - integrity sha512-jDWGIQ96BhXbmONAQsasIpTYWslyjkiGu0Quydjlowe+ciqySpiDUrJHERIRfELE5+wFc7hc1Q5hqjGoV7yghw== +"@algolia/recommend@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.25.0.tgz#b96f12c85aa74a0326982c7801fcd4a610b420f4" + integrity sha512-79sMdHpiRLXVxSjgw7Pt4R1aNUHxFLHiaTDnN2MQjHwJ1+o3wSseb55T9VXU4kqy3m7TUme3pyRhLk5ip/S4Mw== dependencies: - "@algolia/requester-common" "4.23.3" + "@algolia/client-common" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" -"@algolia/requester-common@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.23.3.tgz#7dbae896e41adfaaf1d1fa5f317f83a99afb04b3" - integrity sha512-xloIdr/bedtYEGcXCiF2muajyvRhwop4cMZo+K2qzNht0CMzlRkm8YsDdj5IaBhshqfgmBb3rTg4sL4/PpvLYw== - -"@algolia/requester-node-http@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-4.23.3.tgz#c9f94a5cb96a15f48cea338ab6ef16bbd0ff989f" - integrity sha512-zgu++8Uj03IWDEJM3fuNl34s746JnZOWn1Uz5taV1dFyJhVM/kTNw9Ik7YJWiUNHJQXcaD8IXD1eCb0nq/aByA== +"@algolia/requester-browser-xhr@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.25.0.tgz#c194fa5f49206b9343e6646c41bfbca2a3f2ac54" + integrity sha512-JLaF23p1SOPBmfEqozUAgKHQrGl3z/Z5RHbggBu6s07QqXXcazEsub5VLonCxGVqTv6a61AAPr8J1G5HgGGjEw== dependencies: - "@algolia/requester-common" "4.23.3" + "@algolia/client-common" "5.25.0" -"@algolia/transporter@4.23.3": - version "4.23.3" - resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.23.3.tgz#545b045b67db3850ddf0bbecbc6c84ff1f3398b7" - integrity sha512-Wjl5gttqnf/gQKJA+dafnD0Y6Yw97yvfY8R9h0dQltX1GXTgNs1zWgvtWW0tHl1EgMdhAyw189uWiZMnL3QebQ== +"@algolia/requester-fetch@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.25.0.tgz#231a2d0da2397d141f80b8f28e2cb6e3d219d38d" + integrity sha512-rtzXwqzFi1edkOF6sXxq+HhmRKDy7tz84u0o5t1fXwz0cwx+cjpmxu/6OQKTdOJFS92JUYHsG51Iunie7xbqfQ== dependencies: - "@algolia/cache-common" "4.23.3" - "@algolia/logger-common" "4.23.3" - "@algolia/requester-common" "4.23.3" + "@algolia/client-common" "5.25.0" + +"@algolia/requester-node-http@5.25.0": + version "5.25.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.25.0.tgz#0ce13c550890de21c558b04381535d2d245a3725" + integrity sha512-ZO0UKvDyEFvyeJQX0gmZDQEvhLZ2X10K+ps6hViMo1HgE2V8em00SwNsQ+7E/52a+YiBkVWX61pJJJE44juDMQ== + dependencies: + "@algolia/client-common" "5.25.0" "@alloc/quick-lru@^5.2.0": version "5.2.0" @@ -168,6 +163,19 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@antfu/install-pkg@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz#78fa036be1a6081b5a77a5cf59f50c7752b6ba26" + integrity sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ== + dependencies: + package-manager-detector "^1.3.0" + tinyexec "^1.0.1" + +"@antfu/utils@^8.1.0": + version "8.1.1" + resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-8.1.1.tgz#95b1947d292a9a2efffba2081796dcaa05ecedfb" + integrity sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ== + "@apidevtools/json-schema-ref-parser@^11.5.4": version "11.6.4" resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.4.tgz#0f3e02302f646471d621a8850e6a346d63c8ebd4" @@ -177,7 +185,7 @@ "@types/json-schema" "^7.0.15" js-yaml "^4.1.0" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.8.3": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" integrity sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA== @@ -194,12 +202,26 @@ js-tokens "^4.0.0" picocolors "^1.0.0" +"@babel/code-frame@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.27.1.tgz#200f715e66d52a23b221a9435534a91cc13ad5be" + integrity sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg== + dependencies: + "@babel/helper-validator-identifier" "^7.27.1" + js-tokens "^4.0.0" + picocolors "^1.1.1" + "@babel/compat-data@^7.22.6", "@babel/compat-data@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.7.tgz#d23bbea508c3883ba8251fb4164982c36ea577ed" integrity sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw== -"@babel/core@^7.21.3", "@babel/core@^7.23.3": +"@babel/compat-data@^7.27.2": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.3.tgz#cc49c2ac222d69b889bf34c795f537c0c6311111" + integrity sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw== + +"@babel/core@^7.21.3": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.7.tgz#b676450141e0b52a3d43bc91da86aa608f950ac4" integrity sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g== @@ -220,7 +242,28 @@ json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.23.3", "@babel/generator@^7.24.7": +"@babel/core@^7.25.9": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.3.tgz#d7d05502bccede3cab36373ed142e6a1df554c2f" + integrity sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.27.3" + "@babel/helpers" "^7.27.3" + "@babel/parser" "^7.27.3" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.27.3" + "@babel/types" "^7.27.3" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.7.tgz#1654d01de20ad66b4b4d99c135471bc654c55e6d" integrity sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA== @@ -230,6 +273,17 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^2.5.1" +"@babel/generator@^7.25.9", "@babel/generator@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.3.tgz#ef1c0f7cfe3b5fc8cbb9f6cc69f93441a68edefc" + integrity sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q== + dependencies: + "@babel/parser" "^7.27.3" + "@babel/types" "^7.27.3" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz#5373c7bc8366b12a033b4be1ac13a206c6656aab" @@ -237,6 +291,13 @@ dependencies: "@babel/types" "^7.24.7" +"@babel/helper-annotate-as-pure@^7.27.1": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" + integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg== + dependencies: + "@babel/types" "^7.27.3" + "@babel/helper-builder-binary-assignment-operator-visitor@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz#37d66feb012024f2422b762b9b2a7cfe27c7fba3" @@ -256,6 +317,17 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz#46a0f6efab808d51d29ce96858dd10ce8732733d" + integrity sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-validator-option" "^7.27.1" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + "@babel/helper-create-class-features-plugin@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.24.7.tgz#2eaed36b3a1c11c53bdf80d53838b293c52f5b3b" @@ -271,6 +343,19 @@ "@babel/helper-split-export-declaration" "^7.24.7" semver "^6.3.1" +"@babel/helper-create-class-features-plugin@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz#5bee4262a6ea5ddc852d0806199eb17ca3de9281" + integrity sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-member-expression-to-functions" "^7.27.1" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/traverse" "^7.27.1" + semver "^6.3.1" + "@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.24.7.tgz#be4f435a80dc2b053c76eeb4b7d16dd22cfc89da" @@ -280,6 +365,15 @@ regexpu-core "^5.3.1" semver "^6.3.1" +"@babel/helper-create-regexp-features-plugin@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz#05b0882d97ba1d4d03519e4bce615d70afa18c53" + integrity sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + regexpu-core "^6.2.0" + semver "^6.3.1" + "@babel/helper-define-polyfill-provider@^0.6.1", "@babel/helper-define-polyfill-provider@^0.6.2": version "0.6.2" resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz#18594f789c3594acb24cfdb4a7f7b7d2e8bd912d" @@ -291,6 +385,17 @@ lodash.debounce "^4.0.8" resolve "^1.14.2" +"@babel/helper-define-polyfill-provider@^0.6.3": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz#15e8746368bfa671785f5926ff74b3064c291fab" + integrity sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw== + dependencies: + "@babel/helper-compilation-targets" "^7.22.6" + "@babel/helper-plugin-utils" "^7.22.5" + debug "^4.1.1" + lodash.debounce "^4.0.8" + resolve "^1.14.2" + "@babel/helper-environment-visitor@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz#4b31ba9551d1f90781ba83491dd59cf9b269f7d9" @@ -321,6 +426,14 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/helper-member-expression-to-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz#ea1211276be93e798ce19037da6f06fbb994fa44" + integrity sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + "@babel/helper-module-imports@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz#f2f980392de5b84c3328fc71d38bd81bbb83042b" @@ -329,6 +442,14 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/helper-module-imports@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz#7ef769a323e2655e126673bb6d2d6913bbead204" + integrity sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + "@babel/helper-module-transforms@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz#31b6c9a2930679498db65b685b1698bfd6c7daf8" @@ -340,6 +461,15 @@ "@babel/helper-split-export-declaration" "^7.24.7" "@babel/helper-validator-identifier" "^7.24.7" +"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz#db0bbcfba5802f9ef7870705a7ef8788508ede02" + integrity sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.3" + "@babel/helper-optimise-call-expression@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz#8b0a0456c92f6b323d27cfd00d1d664e76692a0f" @@ -347,11 +477,23 @@ dependencies: "@babel/types" "^7.24.7" +"@babel/helper-optimise-call-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200" + integrity sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw== + dependencies: + "@babel/types" "^7.27.1" + "@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz#98c84fe6fe3d0d3ae7bfc3a5e166a46844feb2a0" integrity sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg== +"@babel/helper-plugin-utils@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz#ddb2f876534ff8013e6c2b299bf4d39b3c51d44c" + integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw== + "@babel/helper-remap-async-to-generator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.24.7.tgz#b3f0f203628522713849d49403f1a414468be4c7" @@ -361,6 +503,15 @@ "@babel/helper-environment-visitor" "^7.24.7" "@babel/helper-wrap-function" "^7.24.7" +"@babel/helper-remap-async-to-generator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz#4601d5c7ce2eb2aea58328d43725523fcd362ce6" + integrity sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-wrap-function" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/helper-replace-supers@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.24.7.tgz#f933b7eed81a1c0265740edc91491ce51250f765" @@ -370,6 +521,15 @@ "@babel/helper-member-expression-to-functions" "^7.24.7" "@babel/helper-optimise-call-expression" "^7.24.7" +"@babel/helper-replace-supers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz#b1ed2d634ce3bdb730e4b52de30f8cccfd692bc0" + integrity sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA== + dependencies: + "@babel/helper-member-expression-to-functions" "^7.27.1" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/helper-simple-access@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz#bcade8da3aec8ed16b9c4953b74e506b51b5edb3" @@ -386,6 +546,14 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/helper-skip-transparent-expression-wrappers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz#62bb91b3abba8c7f1fec0252d9dbea11b3ee7a56" + integrity sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg== + dependencies: + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + "@babel/helper-split-export-declaration@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz#83949436890e07fa3d6873c61a96e3bbf692d856" @@ -403,6 +571,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz#75b889cfaf9e35c2aaf42cf0d72c8e91719251db" @@ -413,11 +586,21 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== +"@babel/helper-validator-identifier@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz#a7054dcc145a967dd4dc8fee845a57c1316c9df8" + integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== + "@babel/helper-validator-option@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz#24c3bb77c7a425d1742eec8fb433b5a1b38e62f6" integrity sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw== +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== + "@babel/helper-wrap-function@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.24.7.tgz#52d893af7e42edca7c6d2c6764549826336aae1f" @@ -428,6 +611,15 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/helper-wrap-function@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz#b88285009c31427af318d4fe37651cd62a142409" + integrity sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ== + dependencies: + "@babel/template" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" + "@babel/helpers@^7.24.7": version "7.27.0" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.0.tgz#53d156098defa8243eab0f32fa17589075a1b808" @@ -436,6 +628,14 @@ "@babel/template" "^7.27.0" "@babel/types" "^7.27.0" +"@babel/helpers@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.3.tgz#387d65d279290e22fe7a47a8ffcd2d0c0184edd0" + integrity sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg== + dependencies: + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + "@babel/highlight@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" @@ -458,6 +658,13 @@ dependencies: "@babel/types" "^7.27.0" +"@babel/parser@^7.27.2", "@babel/parser@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.3.tgz#1b7533f0d908ad2ac545c4d05cbe2fb6dc8cfaaf" + integrity sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw== + dependencies: + "@babel/types" "^7.27.3" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.24.7.tgz#fd059fd27b184ea2b4c7e646868a9a381bbc3055" @@ -466,6 +673,21 @@ "@babel/helper-environment-visitor" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz#61dd8a8e61f7eb568268d1b5f129da3eee364bf9" + integrity sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" + +"@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz#43f70a6d7efd52370eefbdf55ae03d91b293856d" + integrity sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.24.7.tgz#468096ca44bbcbe8fcc570574e12eb1950e18107" @@ -473,6 +695,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz#beb623bd573b8b6f3047bd04c32506adc3e58a72" + integrity sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz#e4eabdd5109acc399b38d7999b2ef66fc2022f89" @@ -482,6 +711,15 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" "@babel/plugin-transform-optional-chaining" "^7.24.7" +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz#e134a5479eb2ba9c02714e8c1ebf1ec9076124fd" + integrity sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-optional-chaining" "^7.27.1" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.24.7.tgz#71b21bb0286d5810e63a1538aa901c58e87375ec" @@ -490,6 +728,14 @@ "@babel/helper-environment-visitor" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz#bb1c25af34d75115ce229a1de7fa44bf8f955670" + integrity sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2": version "7.21.0-placeholder-for-preset-env.2" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703" @@ -537,6 +783,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-syntax-import-assertions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz#88894aefd2b03b5ee6ad1562a7c8e1587496aecd" + integrity sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-import-attributes@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz#b4f9ea95a79e6912480c4b626739f86a076624ca" @@ -544,6 +797,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-syntax-import-attributes@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz#34c017d54496f9b11b61474e7ea3dfd5563ffe07" + integrity sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-import-meta@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" @@ -565,6 +825,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-syntax-jsx@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz#2f9beb5eff30fa507c5532d107daac7b888fa34c" + integrity sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -628,6 +895,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-syntax-typescript@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18" + integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-unicode-sets-regex@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357" @@ -643,6 +917,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-arrow-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a" + integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-async-generator-functions@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.24.7.tgz#7330a5c50e05181ca52351b8fd01642000c96cfd" @@ -653,6 +934,15 @@ "@babel/helper-remap-async-to-generator" "^7.24.7" "@babel/plugin-syntax-async-generators" "^7.8.4" +"@babel/plugin-transform-async-generator-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz#ca433df983d68e1375398e7ca71bf2a4f6fd89d7" + integrity sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-remap-async-to-generator" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/plugin-transform-async-to-generator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz#72a3af6c451d575842a7e9b5a02863414355bdcc" @@ -662,6 +952,15 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/helper-remap-async-to-generator" "^7.24.7" +"@babel/plugin-transform-async-to-generator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz#9a93893b9379b39466c74474f55af03de78c66e7" + integrity sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-remap-async-to-generator" "^7.27.1" + "@babel/plugin-transform-block-scoped-functions@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz#a4251d98ea0c0f399dafe1a35801eaba455bbf1f" @@ -669,6 +968,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-block-scoped-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz#558a9d6e24cf72802dd3b62a4b51e0d62c0f57f9" + integrity sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-block-scoping@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.24.7.tgz#42063e4deb850c7bd7c55e626bf4e7ab48e6ce02" @@ -676,6 +982,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-block-scoping@^7.27.1": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.3.tgz#a21f37e222dc0a7b91c3784fa3bd4edf8d7a6dc1" + integrity sha512-+F8CnfhuLhwUACIJMLWnjz6zvzYM2r0yeIHKlbgfw7ml8rOMJsXNXV/hyRcb3nb493gRs4WvYpQAndWj/qQmkQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-class-properties@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.7.tgz#256879467b57b0b68c7ddfc5b76584f398cd6834" @@ -684,6 +997,14 @@ "@babel/helper-create-class-features-plugin" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-class-properties@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz#dd40a6a370dfd49d32362ae206ddaf2bb082a925" + integrity sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-class-static-block@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz#c82027ebb7010bc33c116d4b5044fbbf8c05484d" @@ -693,6 +1014,14 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-class-static-block" "^7.14.5" +"@babel/plugin-transform-class-static-block@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz#7e920d5625b25bbccd3061aefbcc05805ed56ce4" + integrity sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-classes@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.7.tgz#4ae6ef43a12492134138c1e45913f7c46c41b4bf" @@ -707,6 +1036,18 @@ "@babel/helper-split-export-declaration" "^7.24.7" globals "^11.1.0" +"@babel/plugin-transform-classes@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz#03bb04bea2c7b2f711f0db7304a8da46a85cced4" + integrity sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-compilation-targets" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/traverse" "^7.27.1" + globals "^11.1.0" + "@babel/plugin-transform-computed-properties@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz#4cab3214e80bc71fae3853238d13d097b004c707" @@ -715,6 +1056,14 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/template" "^7.24.7" +"@babel/plugin-transform-computed-properties@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz#81662e78bf5e734a97982c2b7f0a793288ef3caa" + integrity sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/template" "^7.27.1" + "@babel/plugin-transform-destructuring@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.7.tgz#a097f25292defb6e6cc16d6333a4cfc1e3c72d9e" @@ -722,6 +1071,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-destructuring@^7.27.1", "@babel/plugin-transform-destructuring@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.3.tgz#3cc8299ed798d9a909f8d66ddeb40849ec32e3b0" + integrity sha512-s4Jrok82JpiaIprtY2nHsYmrThKvvwgHwjgd7UMiYhZaN0asdXNLr0y+NjTfkA7SyQE5i2Fb7eawUOZmLvyqOA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-dotall-regex@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz#5f8bf8a680f2116a7207e16288a5f974ad47a7a0" @@ -730,6 +1086,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-dotall-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz#aa6821de864c528b1fecf286f0a174e38e826f4d" + integrity sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-duplicate-keys@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz#dd20102897c9a2324e5adfffb67ff3610359a8ee" @@ -737,6 +1101,21 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-duplicate-keys@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz#f1fbf628ece18e12e7b32b175940e68358f546d1" + integrity sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz#5043854ca620a94149372e69030ff8cb6a9eb0ec" + integrity sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-dynamic-import@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz#4d8b95e3bae2b037673091aa09cd33fecd6419f4" @@ -745,6 +1124,13 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-dynamic-import" "^7.8.3" +"@babel/plugin-transform-dynamic-import@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz#4c78f35552ac0e06aa1f6e3c573d67695e8af5a4" + integrity sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-exponentiation-operator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz#b629ee22645f412024297d5245bce425c31f9b0d" @@ -753,6 +1139,13 @@ "@babel/helper-builder-binary-assignment-operator-visitor" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-exponentiation-operator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz#fc497b12d8277e559747f5a3ed868dd8064f83e1" + integrity sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-export-namespace-from@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz#176d52d8d8ed516aeae7013ee9556d540c53f197" @@ -761,6 +1154,13 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-export-namespace-from" "^7.8.3" +"@babel/plugin-transform-export-namespace-from@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz#71ca69d3471edd6daa711cf4dfc3400415df9c23" + integrity sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-for-of@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz#f25b33f72df1d8be76399e1b8f3f9d366eb5bc70" @@ -769,6 +1169,14 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" +"@babel/plugin-transform-for-of@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz#bc24f7080e9ff721b63a70ac7b2564ca15b6c40a" + integrity sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-function-name@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.24.7.tgz#6d8601fbffe665c894440ab4470bc721dd9131d6" @@ -778,6 +1186,15 @@ "@babel/helper-function-name" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-function-name@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz#4d0bf307720e4dce6d7c30fcb1fd6ca77bdeb3a7" + integrity sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ== + dependencies: + "@babel/helper-compilation-targets" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/plugin-transform-json-strings@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz#f3e9c37c0a373fee86e36880d45b3664cedaf73a" @@ -786,6 +1203,13 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-json-strings" "^7.8.3" +"@babel/plugin-transform-json-strings@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz#a2e0ce6ef256376bd527f290da023983527a4f4c" + integrity sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-literals@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.24.7.tgz#36b505c1e655151a9d7607799a9988fc5467d06c" @@ -793,6 +1217,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz#baaefa4d10a1d4206f9dcdda50d7d5827bb70b24" + integrity sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-logical-assignment-operators@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz#a58fb6eda16c9dc8f9ff1c7b1ba6deb7f4694cb0" @@ -801,6 +1232,13 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" +"@babel/plugin-transform-logical-assignment-operators@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz#890cb20e0270e0e5bebe3f025b434841c32d5baa" + integrity sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-member-expression-literals@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz#3b4454fb0e302e18ba4945ba3246acb1248315df" @@ -808,6 +1246,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-member-expression-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz#37b88ba594d852418e99536f5612f795f23aeaf9" + integrity sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-modules-amd@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz#65090ed493c4a834976a3ca1cde776e6ccff32d7" @@ -816,6 +1261,14 @@ "@babel/helper-module-transforms" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-modules-amd@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz#a4145f9d87c2291fe2d05f994b65dba4e3e7196f" + integrity sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA== + dependencies: + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-modules-commonjs@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.7.tgz#9fd5f7fdadee9085886b183f1ad13d1ab260f4ab" @@ -825,6 +1278,14 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/helper-simple-access" "^7.24.7" +"@babel/plugin-transform-modules-commonjs@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz#8e44ed37c2787ecc23bdc367f49977476614e832" + integrity sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw== + dependencies: + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-modules-systemjs@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.24.7.tgz#f8012316c5098f6e8dee6ecd58e2bc6f003d0ce7" @@ -835,6 +1296,16 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/helper-validator-identifier" "^7.24.7" +"@babel/plugin-transform-modules-systemjs@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz#00e05b61863070d0f3292a00126c16c0e024c4ed" + integrity sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA== + dependencies: + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/traverse" "^7.27.1" + "@babel/plugin-transform-modules-umd@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz#edd9f43ec549099620df7df24e7ba13b5c76efc8" @@ -843,6 +1314,14 @@ "@babel/helper-module-transforms" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-modules-umd@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz#63f2cf4f6dc15debc12f694e44714863d34cd334" + integrity sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w== + dependencies: + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-named-capturing-groups-regex@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz#9042e9b856bc6b3688c0c2e4060e9e10b1460923" @@ -851,6 +1330,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-named-capturing-groups-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz#f32b8f7818d8fc0cc46ee20a8ef75f071af976e1" + integrity sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-new-target@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz#31ff54c4e0555cc549d5816e4ab39241dfb6ab00" @@ -858,6 +1345,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-new-target@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz#259c43939728cad1706ac17351b7e6a7bea1abeb" + integrity sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz#1de4534c590af9596f53d67f52a92f12db984120" @@ -866,6 +1360,13 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" +"@babel/plugin-transform-nullish-coalescing-operator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz#4f9d3153bf6782d73dd42785a9d22d03197bc91d" + integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-numeric-separator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz#bea62b538c80605d8a0fac9b40f48e97efa7de63" @@ -874,6 +1375,13 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-numeric-separator" "^7.10.4" +"@babel/plugin-transform-numeric-separator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz#614e0b15cc800e5997dadd9bd6ea524ed6c819c6" + integrity sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-object-rest-spread@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz#d13a2b93435aeb8a197e115221cab266ba6e55d6" @@ -884,6 +1392,16 @@ "@babel/plugin-syntax-object-rest-spread" "^7.8.3" "@babel/plugin-transform-parameters" "^7.24.7" +"@babel/plugin-transform-object-rest-spread@^7.27.2": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.3.tgz#ce130aa73fef828bc3e3e835f9bc6144be3eb1c0" + integrity sha512-7ZZtznF9g4l2JCImCo5LNKFHB5eXnN39lLtLY5Tg+VkR0jwOt7TBciMckuiQIOIW7L5tkQOCh3bVGYeXgMx52Q== + dependencies: + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-destructuring" "^7.27.3" + "@babel/plugin-transform-parameters" "^7.27.1" + "@babel/plugin-transform-object-super@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz#66eeaff7830bba945dd8989b632a40c04ed625be" @@ -892,6 +1410,14 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/helper-replace-supers" "^7.24.7" +"@babel/plugin-transform-object-super@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz#1c932cd27bf3874c43a5cac4f43ebf970c9871b5" + integrity sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" + "@babel/plugin-transform-optional-catch-binding@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz#00eabd883d0dd6a60c1c557548785919b6e717b4" @@ -900,6 +1426,13 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" +"@babel/plugin-transform-optional-catch-binding@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz#84c7341ebde35ccd36b137e9e45866825072a30c" + integrity sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-optional-chaining@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.7.tgz#b8f6848a80cf2da98a8a204429bec04756c6d454" @@ -909,6 +1442,14 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" "@babel/plugin-syntax-optional-chaining" "^7.8.3" +"@babel/plugin-transform-optional-chaining@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz#874ce3c4f06b7780592e946026eb76a32830454f" + integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-parameters@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz#5881f0ae21018400e320fc7eb817e529d1254b68" @@ -916,6 +1457,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-parameters@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz#80334b54b9b1ac5244155a0c8304a187a618d5a7" + integrity sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-private-methods@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.7.tgz#e6318746b2ae70a59d023d5cc1344a2ba7a75f5e" @@ -924,6 +1472,14 @@ "@babel/helper-create-class-features-plugin" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-private-methods@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz#fdacbab1c5ed81ec70dfdbb8b213d65da148b6af" + integrity sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-private-property-in-object@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz#4eec6bc701288c1fab5f72e6a4bbc9d67faca061" @@ -934,6 +1490,15 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-private-property-in-object" "^7.14.5" +"@babel/plugin-transform-private-property-in-object@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz#4dbbef283b5b2f01a21e81e299f76e35f900fb11" + integrity sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-property-literals@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz#f0d2ed8380dfbed949c42d4d790266525d63bbdc" @@ -941,6 +1506,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-property-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz#07eafd618800591e88073a0af1b940d9a42c6424" + integrity sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-react-constant-elements@^7.21.3": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.24.7.tgz#b85e8f240b14400277f106c9c9b585d9acf608a1" @@ -955,6 +1527,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-react-display-name@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.27.1.tgz#43af31362d71f7848cfac0cbc212882b1a16e80f" + integrity sha512-p9+Vl3yuHPmkirRrg021XiP+EETmPMQTLr6Ayjj85RLNEbb3Eya/4VI0vAdzQG9SEAl2Lnt7fy5lZyMzjYoZQQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-react-jsx-development@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.24.7.tgz#eaee12f15a93f6496d852509a850085e6361470b" @@ -962,6 +1541,13 @@ dependencies: "@babel/plugin-transform-react-jsx" "^7.24.7" +"@babel/plugin-transform-react-jsx-development@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz#47ff95940e20a3a70e68ad3d4fcb657b647f6c98" + integrity sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q== + dependencies: + "@babel/plugin-transform-react-jsx" "^7.27.1" + "@babel/plugin-transform-react-jsx@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.24.7.tgz#17cd06b75a9f0e2bd076503400e7c4b99beedac4" @@ -973,6 +1559,17 @@ "@babel/plugin-syntax-jsx" "^7.24.7" "@babel/types" "^7.24.7" +"@babel/plugin-transform-react-jsx@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz#1023bc94b78b0a2d68c82b5e96aed573bcfb9db0" + integrity sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/types" "^7.27.1" + "@babel/plugin-transform-react-pure-annotations@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.7.tgz#bdd9d140d1c318b4f28b29a00fb94f97ecab1595" @@ -981,6 +1578,14 @@ "@babel/helper-annotate-as-pure" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-react-pure-annotations@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz#339f1ce355eae242e0649f232b1c68907c02e879" + integrity sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-regenerator@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz#021562de4534d8b4b1851759fd7af4e05d2c47f8" @@ -989,6 +1594,21 @@ "@babel/helper-plugin-utils" "^7.24.7" regenerator-transform "^0.15.2" +"@babel/plugin-transform-regenerator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz#0a471df9213416e44cd66bf67176b66f65768401" + integrity sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-regexp-modifiers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz#df9ba5577c974e3f1449888b70b76169998a6d09" + integrity sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-reserved-words@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz#80037fe4fbf031fc1125022178ff3938bb3743a4" @@ -996,15 +1616,22 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" -"@babel/plugin-transform-runtime@^7.22.9": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.24.7.tgz#00a5bfaf8c43cf5c8703a8a6e82b59d9c58f38ca" - integrity sha512-YqXjrk4C+a1kZjewqt+Mmu2UuV1s07y8kqcUf4qYLnoqemhR4gRQikhdAhSVJioMjVTu6Mo6pAbaypEA3jY6fw== +"@babel/plugin-transform-reserved-words@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz#40fba4878ccbd1c56605a4479a3a891ac0274bb4" + integrity sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw== dependencies: - "@babel/helper-module-imports" "^7.24.7" - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-runtime@^7.25.9": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.3.tgz#ad35f1eff5ba18a5e23f7270e939fb5a59d3ec0b" + integrity sha512-bA9ZL5PW90YwNgGfjg6U+7Qh/k3zCEQJ06BFgAGRp/yMjw9hP9UGbGPtx3KSOkHGljEPCCxaE+PH4fUR2h1sDw== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" babel-plugin-polyfill-corejs2 "^0.4.10" - babel-plugin-polyfill-corejs3 "^0.10.1" + babel-plugin-polyfill-corejs3 "^0.11.0" babel-plugin-polyfill-regenerator "^0.6.1" semver "^6.3.1" @@ -1015,6 +1642,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-shorthand-properties@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90" + integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-spread@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz#e8a38c0fde7882e0fb8f160378f74bd885cc7bb3" @@ -1023,6 +1657,14 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" +"@babel/plugin-transform-spread@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz#1a264d5fc12750918f50e3fe3e24e437178abb08" + integrity sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-sticky-regex@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz#96ae80d7a7e5251f657b5cf18f1ea6bf926f5feb" @@ -1030,6 +1672,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-sticky-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz#18984935d9d2296843a491d78a014939f7dcd280" + integrity sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-template-literals@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz#a05debb4a9072ae8f985bcf77f3f215434c8f8c8" @@ -1037,6 +1686,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-template-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8" + integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-typeof-symbol@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.7.tgz#f074be466580d47d6e6b27473a840c9f9ca08fb0" @@ -1044,6 +1700,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-typeof-symbol@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz#70e966bb492e03509cf37eafa6dcc3051f844369" + integrity sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-typescript@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.24.7.tgz#b006b3e0094bf0813d505e0c5485679eeaf4a881" @@ -1054,6 +1717,17 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-typescript" "^7.24.7" +"@babel/plugin-transform-typescript@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.27.1.tgz#d3bb65598bece03f773111e88cc4e8e5070f1140" + integrity sha512-Q5sT5+O4QUebHdbwKedFBEwRLb02zJ7r4A5Gg2hUoLuU3FjdMcyqcywqUrLCaDsFCxzokf7u9kuy7qz51YUuAg== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-create-class-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-syntax-typescript" "^7.27.1" + "@babel/plugin-transform-unicode-escapes@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz#2023a82ced1fb4971630a2e079764502c4148e0e" @@ -1061,6 +1735,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-unicode-escapes@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz#3e3143f8438aef842de28816ece58780190cf806" + integrity sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-unicode-property-regex@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz#9073a4cd13b86ea71c3264659590ac086605bbcd" @@ -1069,6 +1750,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-unicode-property-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz#bdfe2d3170c78c5691a3c3be934c8c0087525956" + integrity sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-unicode-regex@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz#dfc3d4a51127108099b19817c0963be6a2adf19f" @@ -1077,6 +1766,14 @@ "@babel/helper-create-regexp-features-plugin" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" +"@babel/plugin-transform-unicode-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97" + integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-unicode-sets-regex@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.7.tgz#d40705d67523803a576e29c63cef6e516b858ed9" @@ -1085,7 +1782,15 @@ "@babel/helper-create-regexp-features-plugin" "^7.24.7" "@babel/helper-plugin-utils" "^7.24.7" -"@babel/preset-env@^7.20.2", "@babel/preset-env@^7.22.9": +"@babel/plugin-transform-unicode-sets-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz#6ab706d10f801b5c72da8bb2548561fa04193cd1" + integrity sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw== + dependencies: + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/preset-env@^7.20.2": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.24.7.tgz#ff067b4e30ba4a72f225f12f123173e77b987f37" integrity sha512-1YZNsc+y6cTvWlDHidMBsQZrZfEFjRIo/BZCT906PMdzOyXtSLTgqGdrpcuTDCXyd11Am5uQULtDIcCfnTc8fQ== @@ -1172,6 +1877,81 @@ core-js-compat "^3.31.0" semver "^6.3.1" +"@babel/preset-env@^7.25.9": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.27.2.tgz#106e6bfad92b591b1f6f76fd4cf13b7725a7bf9a" + integrity sha512-Ma4zSuYSlGNRlCLO+EAzLnCmJK2vdstgv+n7aUP+/IKZrOfWHOJVdSJtuub8RzHTj3ahD37k5OKJWvzf16TQyQ== + dependencies: + "@babel/compat-data" "^7.27.2" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.27.1" + "@babel/plugin-bugfix-safari-class-field-initializer-scope" "^7.27.1" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.27.1" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.27.1" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.27.1" + "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2" + "@babel/plugin-syntax-import-assertions" "^7.27.1" + "@babel/plugin-syntax-import-attributes" "^7.27.1" + "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6" + "@babel/plugin-transform-arrow-functions" "^7.27.1" + "@babel/plugin-transform-async-generator-functions" "^7.27.1" + "@babel/plugin-transform-async-to-generator" "^7.27.1" + "@babel/plugin-transform-block-scoped-functions" "^7.27.1" + "@babel/plugin-transform-block-scoping" "^7.27.1" + "@babel/plugin-transform-class-properties" "^7.27.1" + "@babel/plugin-transform-class-static-block" "^7.27.1" + "@babel/plugin-transform-classes" "^7.27.1" + "@babel/plugin-transform-computed-properties" "^7.27.1" + "@babel/plugin-transform-destructuring" "^7.27.1" + "@babel/plugin-transform-dotall-regex" "^7.27.1" + "@babel/plugin-transform-duplicate-keys" "^7.27.1" + "@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.27.1" + "@babel/plugin-transform-dynamic-import" "^7.27.1" + "@babel/plugin-transform-exponentiation-operator" "^7.27.1" + "@babel/plugin-transform-export-namespace-from" "^7.27.1" + "@babel/plugin-transform-for-of" "^7.27.1" + "@babel/plugin-transform-function-name" "^7.27.1" + "@babel/plugin-transform-json-strings" "^7.27.1" + "@babel/plugin-transform-literals" "^7.27.1" + "@babel/plugin-transform-logical-assignment-operators" "^7.27.1" + "@babel/plugin-transform-member-expression-literals" "^7.27.1" + "@babel/plugin-transform-modules-amd" "^7.27.1" + "@babel/plugin-transform-modules-commonjs" "^7.27.1" + "@babel/plugin-transform-modules-systemjs" "^7.27.1" + "@babel/plugin-transform-modules-umd" "^7.27.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.27.1" + "@babel/plugin-transform-new-target" "^7.27.1" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.27.1" + "@babel/plugin-transform-numeric-separator" "^7.27.1" + "@babel/plugin-transform-object-rest-spread" "^7.27.2" + "@babel/plugin-transform-object-super" "^7.27.1" + "@babel/plugin-transform-optional-catch-binding" "^7.27.1" + "@babel/plugin-transform-optional-chaining" "^7.27.1" + "@babel/plugin-transform-parameters" "^7.27.1" + "@babel/plugin-transform-private-methods" "^7.27.1" + "@babel/plugin-transform-private-property-in-object" "^7.27.1" + "@babel/plugin-transform-property-literals" "^7.27.1" + "@babel/plugin-transform-regenerator" "^7.27.1" + "@babel/plugin-transform-regexp-modifiers" "^7.27.1" + "@babel/plugin-transform-reserved-words" "^7.27.1" + "@babel/plugin-transform-shorthand-properties" "^7.27.1" + "@babel/plugin-transform-spread" "^7.27.1" + "@babel/plugin-transform-sticky-regex" "^7.27.1" + "@babel/plugin-transform-template-literals" "^7.27.1" + "@babel/plugin-transform-typeof-symbol" "^7.27.1" + "@babel/plugin-transform-unicode-escapes" "^7.27.1" + "@babel/plugin-transform-unicode-property-regex" "^7.27.1" + "@babel/plugin-transform-unicode-regex" "^7.27.1" + "@babel/plugin-transform-unicode-sets-regex" "^7.27.1" + "@babel/preset-modules" "0.1.6-no-external-plugins" + babel-plugin-polyfill-corejs2 "^0.4.10" + babel-plugin-polyfill-corejs3 "^0.11.0" + babel-plugin-polyfill-regenerator "^0.6.1" + core-js-compat "^3.40.0" + semver "^6.3.1" + "@babel/preset-modules@0.1.6-no-external-plugins": version "0.1.6-no-external-plugins" resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz#ccb88a2c49c817236861fee7826080573b8a923a" @@ -1181,7 +1961,7 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/preset-react@^7.18.6", "@babel/preset-react@^7.22.5": +"@babel/preset-react@^7.18.6": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.24.7.tgz#480aeb389b2a798880bf1f889199e3641cbb22dc" integrity sha512-AAH4lEkpmzFWrGVlHaxJB7RLH21uPQ9+He+eFLWHmF9IuFQVugz8eAsamaW0DXRrTfco5zj1wWtpdcXJUOfsag== @@ -1193,7 +1973,19 @@ "@babel/plugin-transform-react-jsx-development" "^7.24.7" "@babel/plugin-transform-react-pure-annotations" "^7.24.7" -"@babel/preset-typescript@^7.21.0", "@babel/preset-typescript@^7.22.5": +"@babel/preset-react@^7.25.9": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.27.1.tgz#86ea0a5ca3984663f744be2fd26cb6747c3fd0ec" + integrity sha512-oJHWh2gLhU9dW9HHr42q0cI0/iHHXTLGe39qvpAZZzagHy0MzYLCnCVV0symeRvzmjHyVU7mw2K06E6u/JwbhA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-transform-react-display-name" "^7.27.1" + "@babel/plugin-transform-react-jsx" "^7.27.1" + "@babel/plugin-transform-react-jsx-development" "^7.27.1" + "@babel/plugin-transform-react-pure-annotations" "^7.27.1" + +"@babel/preset-typescript@^7.21.0": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.24.7.tgz#66cd86ea8f8c014855671d5ea9a737139cbbfef1" integrity sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ== @@ -1204,26 +1996,41 @@ "@babel/plugin-transform-modules-commonjs" "^7.24.7" "@babel/plugin-transform-typescript" "^7.24.7" +"@babel/preset-typescript@^7.25.9": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz#190742a6428d282306648a55b0529b561484f912" + integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-syntax-jsx" "^7.27.1" + "@babel/plugin-transform-modules-commonjs" "^7.27.1" + "@babel/plugin-transform-typescript" "^7.27.1" + "@babel/regjsgen@^0.8.0": version "0.8.0" resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime-corejs3@^7.22.6": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.24.7.tgz#65a99097e4c28e6c3a174825591700cc5abd710e" - integrity sha512-eytSX6JLBY6PVAeQa2bFlDx/7Mmln/gaEpsit5a3WEvjGfiIytEsgAwuIXCPM0xvw0v0cJn3ilq0/TvXrW0kgA== +"@babel/runtime-corejs3@^7.25.9": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.3.tgz#b971a4a0a376171e266629152e74ef50e9931f79" + integrity sha512-ZYcgrwb+dkWNcDlsTe4fH1CMdqMDSJ5lWFd1by8Si2pI54XcQjte/+ViIPqAk7EAWisaUxvQ89grv+bNX2x8zg== dependencies: core-js-pure "^3.30.2" - regenerator-runtime "^0.14.0" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.22.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.27.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== dependencies: regenerator-runtime "^0.14.0" +"@babel/runtime@^7.25.9": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.3.tgz#10491113799fb8d77e1d9273384d5d68deeea8f6" + integrity sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw== + "@babel/template@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315" @@ -1242,7 +2049,16 @@ "@babel/parser" "^7.27.0" "@babel/types" "^7.27.0" -"@babel/traverse@^7.22.8", "@babel/traverse@^7.24.7": +"@babel/template@^7.27.1", "@babel/template@^7.27.2": + version "7.27.2" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d" + integrity sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/parser" "^7.27.2" + "@babel/types" "^7.27.1" + +"@babel/traverse@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.7.tgz#de2b900163fa741721ba382163fe46a936c40cf5" integrity sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA== @@ -1258,6 +2074,19 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.3.tgz#8b62a6c2d10f9d921ba7339c90074708509cffae" + integrity sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ== + dependencies: + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.27.3" + "@babel/parser" "^7.27.3" + "@babel/template" "^7.27.2" + "@babel/types" "^7.27.3" + debug "^4.3.1" + globals "^11.1.0" + "@babel/types@^7.21.3", "@babel/types@^7.24.7", "@babel/types@^7.4.4": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.7.tgz#6027fe12bc1aa724cd32ab113fb7f1988f1f66f2" @@ -1275,10 +2104,18 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@braintree/sanitize-url@^6.0.1": - version "6.0.4" - resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" - integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A== +"@babel/types@^7.27.1", "@babel/types@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.3.tgz#c0257bedf33aad6aad1f406d35c44758321eb3ec" + integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + +"@braintree/sanitize-url@^7.0.4": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz#15e19737d946559289b915e5dad3b4c28407735e" + integrity sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw== "@bufbuild/buf-darwin-arm64@1.33.0": version "1.33.0" @@ -1322,138 +2159,543 @@ "@bufbuild/buf-win32-arm64" "1.33.0" "@bufbuild/buf-win32-x64" "1.33.0" +"@chevrotain/cst-dts-gen@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz#5e0863cc57dc45e204ccfee6303225d15d9d4783" + integrity sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ== + dependencies: + "@chevrotain/gast" "11.0.3" + "@chevrotain/types" "11.0.3" + lodash-es "4.17.21" + +"@chevrotain/gast@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-11.0.3.tgz#e84d8880323fe8cbe792ef69ce3ffd43a936e818" + integrity sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q== + dependencies: + "@chevrotain/types" "11.0.3" + lodash-es "4.17.21" + +"@chevrotain/regexp-to-ast@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz#11429a81c74a8e6a829271ce02fc66166d56dcdb" + integrity sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA== + +"@chevrotain/types@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-11.0.3.tgz#f8a03914f7b937f594f56eb89312b3b8f1c91848" + integrity sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ== + +"@chevrotain/utils@11.0.3": + version "11.0.3" + resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224" + integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ== + "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== +"@csstools/cascade-layer-name-parser@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-2.0.5.tgz#43f962bebead0052a9fed1a2deeb11f85efcbc72" + integrity sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A== + +"@csstools/color-helpers@^5.0.2": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@csstools/color-helpers/-/color-helpers-5.0.2.tgz#82592c9a7c2b83c293d9161894e2a6471feb97b8" + integrity sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA== + +"@csstools/css-calc@^2.1.4": + version "2.1.4" + resolved "https://registry.yarnpkg.com/@csstools/css-calc/-/css-calc-2.1.4.tgz#8473f63e2fcd6e459838dd412401d5948f224c65" + integrity sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ== + +"@csstools/css-color-parser@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz#79fc68864dd43c3b6782d2b3828bc0fa9d085c10" + integrity sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg== + dependencies: + "@csstools/color-helpers" "^5.0.2" + "@csstools/css-calc" "^2.1.4" + +"@csstools/css-parser-algorithms@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz#5755370a9a29abaec5515b43c8b3f2cf9c2e3076" + integrity sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ== + +"@csstools/css-tokenizer@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz#333fedabc3fd1a8e5d0100013731cf19e6a8c5d3" + integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw== + +"@csstools/media-query-list-parser@^4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@csstools/media-query-list-parser/-/media-query-list-parser-4.0.3.tgz#7aec77bcb89c2da80ef207e73f474ef9e1b3cdf1" + integrity sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ== + +"@csstools/postcss-cascade-layers@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@csstools/postcss-cascade-layers/-/postcss-cascade-layers-5.0.1.tgz#9640313e64b5e39133de7e38a5aa7f40dc259597" + integrity sha512-XOfhI7GShVcKiKwmPAnWSqd2tBR0uxt+runAxttbSp/LY2U16yAVPmAf7e9q4JJ0d+xMNmpwNDLBXnmRCl3HMQ== + dependencies: + "@csstools/selector-specificity" "^5.0.0" + postcss-selector-parser "^7.0.0" + +"@csstools/postcss-color-function@^4.0.10": + version "4.0.10" + resolved "https://registry.yarnpkg.com/@csstools/postcss-color-function/-/postcss-color-function-4.0.10.tgz#11ad43a66ef2cc794ab826a07df8b5fa9fb47a3a" + integrity sha512-4dY0NBu7NVIpzxZRgh/Q/0GPSz/jLSw0i/u3LTUor0BkQcz/fNhN10mSWBDsL0p9nDb0Ky1PD6/dcGbhACuFTQ== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-color-mix-function@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@csstools/postcss-color-mix-function/-/postcss-color-mix-function-3.0.10.tgz#8c9d0ccfae5c45a9870dd84807ea2995c7a3a514" + integrity sha512-P0lIbQW9I4ShE7uBgZRib/lMTf9XMjJkFl/d6w4EMNHu2qvQ6zljJGEcBkw/NsBtq/6q3WrmgxSS8kHtPMkK4Q== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-color-mix-variadic-function-arguments@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-color-mix-variadic-function-arguments/-/postcss-color-mix-variadic-function-arguments-1.0.0.tgz#0b29cb9b4630d7ed68549db265662d41554a17ed" + integrity sha512-Z5WhouTyD74dPFPrVE7KydgNS9VvnjB8qcdes9ARpCOItb4jTnm7cHp4FhxCRUoyhabD0WVv43wbkJ4p8hLAlQ== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-content-alt-text@^2.0.6": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@csstools/postcss-content-alt-text/-/postcss-content-alt-text-2.0.6.tgz#548862226eac54bab0ee5f1bf3a9981393ab204b" + integrity sha512-eRjLbOjblXq+byyaedQRSrAejKGNAFued+LcbzT+LCL78fabxHkxYjBbxkroONxHHYu2qxhFK2dBStTLPG3jpQ== + dependencies: + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-exponential-functions@^2.0.9": + version "2.0.9" + resolved "https://registry.yarnpkg.com/@csstools/postcss-exponential-functions/-/postcss-exponential-functions-2.0.9.tgz#fc03d1272888cb77e64cc1a7d8a33016e4f05c69" + integrity sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw== + dependencies: + "@csstools/css-calc" "^2.1.4" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + +"@csstools/postcss-font-format-keywords@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-4.0.0.tgz#6730836eb0153ff4f3840416cc2322f129c086e6" + integrity sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw== + dependencies: + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + +"@csstools/postcss-gamut-mapping@^2.0.10": + version "2.0.10" + resolved "https://registry.yarnpkg.com/@csstools/postcss-gamut-mapping/-/postcss-gamut-mapping-2.0.10.tgz#f518d941231d721dbecf5b41e3c441885ff2f28b" + integrity sha512-QDGqhJlvFnDlaPAfCYPsnwVA6ze+8hhrwevYWlnUeSjkkZfBpcCO42SaUD8jiLlq7niouyLgvup5lh+f1qessg== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + +"@csstools/postcss-gradients-interpolation-method@^5.0.10": + version "5.0.10" + resolved "https://registry.yarnpkg.com/@csstools/postcss-gradients-interpolation-method/-/postcss-gradients-interpolation-method-5.0.10.tgz#3146da352c31142a721fdba062ac3a6d11dbbec3" + integrity sha512-HHPauB2k7Oits02tKFUeVFEU2ox/H3OQVrP3fSOKDxvloOikSal+3dzlyTZmYsb9FlY9p5EUpBtz0//XBmy+aw== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-hwb-function@^4.0.10": + version "4.0.10" + resolved "https://registry.yarnpkg.com/@csstools/postcss-hwb-function/-/postcss-hwb-function-4.0.10.tgz#f93f3c457e6440ac37ef9b908feb5d901b417d50" + integrity sha512-nOKKfp14SWcdEQ++S9/4TgRKchooLZL0TUFdun3nI4KPwCjETmhjta1QT4ICQcGVWQTvrsgMM/aLB5We+kMHhQ== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-ic-unit@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@csstools/postcss-ic-unit/-/postcss-ic-unit-4.0.2.tgz#7561e09db65fac8304ceeab9dd3e5c6e43414587" + integrity sha512-lrK2jjyZwh7DbxaNnIUjkeDmU8Y6KyzRBk91ZkI5h8nb1ykEfZrtIVArdIjX4DHMIBGpdHrgP0n4qXDr7OHaKA== + dependencies: + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + +"@csstools/postcss-initial@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@csstools/postcss-initial/-/postcss-initial-2.0.1.tgz#c385bd9d8ad31ad159edd7992069e97ceea4d09a" + integrity sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg== + +"@csstools/postcss-is-pseudo-class@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.1.tgz#12041448fedf01090dd4626022c28b7f7623f58e" + integrity sha512-JLp3POui4S1auhDR0n8wHd/zTOWmMsmK3nQd3hhL6FhWPaox5W7j1se6zXOG/aP07wV2ww0lxbKYGwbBszOtfQ== + dependencies: + "@csstools/selector-specificity" "^5.0.0" + postcss-selector-parser "^7.0.0" + +"@csstools/postcss-light-dark-function@^2.0.9": + version "2.0.9" + resolved "https://registry.yarnpkg.com/@csstools/postcss-light-dark-function/-/postcss-light-dark-function-2.0.9.tgz#9fb080188907539734a9d5311d2a1cb82531ef38" + integrity sha512-1tCZH5bla0EAkFAI2r0H33CDnIBeLUaJh1p+hvvsylJ4svsv2wOmJjJn+OXwUZLXef37GYbRIVKX+X+g6m+3CQ== + dependencies: + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-logical-float-and-clear@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-logical-float-and-clear/-/postcss-logical-float-and-clear-3.0.0.tgz#62617564182cf86ab5d4e7485433ad91e4c58571" + integrity sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ== + +"@csstools/postcss-logical-overflow@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-logical-overflow/-/postcss-logical-overflow-2.0.0.tgz#c6de7c5f04e3d4233731a847f6c62819bcbcfa1d" + integrity sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA== + +"@csstools/postcss-logical-overscroll-behavior@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-logical-overscroll-behavior/-/postcss-logical-overscroll-behavior-2.0.0.tgz#43c03eaecdf34055ef53bfab691db6dc97a53d37" + integrity sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w== + +"@csstools/postcss-logical-resize@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-logical-resize/-/postcss-logical-resize-3.0.0.tgz#4df0eeb1a61d7bd85395e56a5cce350b5dbfdca6" + integrity sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg== + dependencies: + postcss-value-parser "^4.2.0" + +"@csstools/postcss-logical-viewport-units@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@csstools/postcss-logical-viewport-units/-/postcss-logical-viewport-units-3.0.4.tgz#016d98a8b7b5f969e58eb8413447eb801add16fc" + integrity sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ== + dependencies: + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-media-minmax@^2.0.9": + version "2.0.9" + resolved "https://registry.yarnpkg.com/@csstools/postcss-media-minmax/-/postcss-media-minmax-2.0.9.tgz#184252d5b93155ae526689328af6bdf3fc113987" + integrity sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig== + dependencies: + "@csstools/css-calc" "^2.1.4" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/media-query-list-parser" "^4.0.3" + +"@csstools/postcss-media-queries-aspect-ratio-number-values@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@csstools/postcss-media-queries-aspect-ratio-number-values/-/postcss-media-queries-aspect-ratio-number-values-3.0.5.tgz#f485c31ec13d6b0fb5c528a3474334a40eff5f11" + integrity sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg== + dependencies: + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/media-query-list-parser" "^4.0.3" + +"@csstools/postcss-nested-calc@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-nested-calc/-/postcss-nested-calc-4.0.0.tgz#754e10edc6958d664c11cde917f44ba144141c62" + integrity sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A== + dependencies: + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + +"@csstools/postcss-normalize-display-values@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-normalize-display-values/-/postcss-normalize-display-values-4.0.0.tgz#ecdde2daf4e192e5da0c6fd933b6d8aff32f2a36" + integrity sha512-HlEoG0IDRoHXzXnkV4in47dzsxdsjdz6+j7MLjaACABX2NfvjFS6XVAnpaDyGesz9gK2SC7MbNwdCHusObKJ9Q== + dependencies: + postcss-value-parser "^4.2.0" + +"@csstools/postcss-oklab-function@^4.0.10": + version "4.0.10" + resolved "https://registry.yarnpkg.com/@csstools/postcss-oklab-function/-/postcss-oklab-function-4.0.10.tgz#d4c23c51dd0be45e6dedde22432d7d0003710780" + integrity sha512-ZzZUTDd0fgNdhv8UUjGCtObPD8LYxMH+MJsW9xlZaWTV8Ppr4PtxlHYNMmF4vVWGl0T6f8tyWAKjoI6vePSgAg== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-progressive-custom-properties@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-progressive-custom-properties/-/postcss-progressive-custom-properties-4.1.0.tgz#70c8d41b577f4023633b7e3791604e0b7f3775bc" + integrity sha512-YrkI9dx8U4R8Sz2EJaoeD9fI7s7kmeEBfmO+UURNeL6lQI7VxF6sBE+rSqdCBn4onwqmxFdBU3lTwyYb/lCmxA== + dependencies: + postcss-value-parser "^4.2.0" + +"@csstools/postcss-random-function@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@csstools/postcss-random-function/-/postcss-random-function-2.0.1.tgz#3191f32fe72936e361dadf7dbfb55a0209e2691e" + integrity sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w== + dependencies: + "@csstools/css-calc" "^2.1.4" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + +"@csstools/postcss-relative-color-syntax@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@csstools/postcss-relative-color-syntax/-/postcss-relative-color-syntax-3.0.10.tgz#daa840583969461e1e06b12e9c591e52a790ec86" + integrity sha512-8+0kQbQGg9yYG8hv0dtEpOMLwB9M+P7PhacgIzVzJpixxV4Eq9AUQtQw8adMmAJU1RBBmIlpmtmm3XTRd/T00g== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +"@csstools/postcss-scope-pseudo-class@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@csstools/postcss-scope-pseudo-class/-/postcss-scope-pseudo-class-4.0.1.tgz#9fe60e9d6d91d58fb5fc6c768a40f6e47e89a235" + integrity sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q== + dependencies: + postcss-selector-parser "^7.0.0" + +"@csstools/postcss-sign-functions@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@csstools/postcss-sign-functions/-/postcss-sign-functions-1.1.4.tgz#a9ac56954014ae4c513475b3f1b3e3424a1e0c12" + integrity sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg== + dependencies: + "@csstools/css-calc" "^2.1.4" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + +"@csstools/postcss-stepped-value-functions@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@csstools/postcss-stepped-value-functions/-/postcss-stepped-value-functions-4.0.9.tgz#36036f1a0e5e5ee2308e72f3c9cb433567c387b9" + integrity sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA== + dependencies: + "@csstools/css-calc" "^2.1.4" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + +"@csstools/postcss-text-decoration-shorthand@^4.0.2": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@csstools/postcss-text-decoration-shorthand/-/postcss-text-decoration-shorthand-4.0.2.tgz#a3bcf80492e6dda36477538ab8e8943908c9f80a" + integrity sha512-8XvCRrFNseBSAGxeaVTaNijAu+FzUvjwFXtcrynmazGb/9WUdsPCpBX+mHEHShVRq47Gy4peYAoxYs8ltUnmzA== + dependencies: + "@csstools/color-helpers" "^5.0.2" + postcss-value-parser "^4.2.0" + +"@csstools/postcss-trigonometric-functions@^4.0.9": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@csstools/postcss-trigonometric-functions/-/postcss-trigonometric-functions-4.0.9.tgz#3f94ed2e319b57f2c59720b64e4d0a8a6fb8c3b2" + integrity sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A== + dependencies: + "@csstools/css-calc" "^2.1.4" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + +"@csstools/postcss-unset-value@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@csstools/postcss-unset-value/-/postcss-unset-value-4.0.0.tgz#7caa981a34196d06a737754864baf77d64de4bba" + integrity sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA== + +"@csstools/selector-resolve-nested@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.0.0.tgz#704a9b637975680e025e069a4c58b3beb3e2752a" + integrity sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ== + +"@csstools/selector-specificity@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@csstools/selector-specificity/-/selector-specificity-5.0.0.tgz#037817b574262134cabd68fc4ec1a454f168407b" + integrity sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw== + +"@csstools/utilities@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@csstools/utilities/-/utilities-2.0.0.tgz#f7ff0fee38c9ffb5646d47b6906e0bc8868bde60" + integrity sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ== + "@discoveryjs/json-ext@0.5.7": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@docsearch/css@3.6.0": - version "3.6.0" - resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.6.0.tgz#0e9f56f704b3a34d044d15fd9962ebc1536ba4fb" - integrity sha512-+sbxb71sWre+PwDK7X2T8+bhS6clcVMLwBPznX45Qu6opJcgRjAp7gYSDzVFp187J+feSj5dNBN1mJoi6ckkUQ== +"@docsearch/css@3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.9.0.tgz#3bc29c96bf024350d73b0cfb7c2a7b71bf251cd5" + integrity sha512-cQbnVbq0rrBwNAKegIac/t6a8nWoUAn8frnkLFW6YARaRmAQr5/Eoe6Ln2fqkUCZ40KpdrKbpSAmgrkviOxuWA== -"@docsearch/react@^3.5.2": - version "3.6.0" - resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-3.6.0.tgz#b4f25228ecb7fc473741aefac592121e86dd2958" - integrity sha512-HUFut4ztcVNmqy9gp/wxNbC7pTOHhgVVkHVGCACTuLhUKUhKAF9KYHJtMiLUJxEqiFLQiuri1fWF8zqwM/cu1w== +"@docsearch/react@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-3.9.0.tgz#d0842b700c3ee26696786f3c8ae9f10c1a3f0db3" + integrity sha512-mb5FOZYZIkRQ6s/NWnM98k879vu5pscWqTLubLFBO87igYYT4VzVazh4h5o/zCvTIZgEt3PvsCOMOswOUo9yHQ== dependencies: - "@algolia/autocomplete-core" "1.9.3" - "@algolia/autocomplete-preset-algolia" "1.9.3" - "@docsearch/css" "3.6.0" - algoliasearch "^4.19.1" + "@algolia/autocomplete-core" "1.17.9" + "@algolia/autocomplete-preset-algolia" "1.17.9" + "@docsearch/css" "3.9.0" + algoliasearch "^5.14.2" -"@docusaurus/core@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.4.0.tgz#bdbf1af4b2f25d1bf4a5b62ec6137d84c821cb3c" - integrity sha512-g+0wwmN2UJsBqy2fQRQ6fhXruoEa62JDeEa5d8IdTJlMoaDaEDfHh7WjwGRn4opuTQWpjAwP/fbcgyHKlE+64w== +"@docusaurus/babel@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.8.0.tgz#2f390cc4e588a96ec496d87921e44890899738a6" + integrity sha512-9EJwSgS6TgB8IzGk1L8XddJLhZod8fXT4ULYMx6SKqyCBqCFpVCEjR/hNXXhnmtVM2irDuzYoVLGWv7srG/VOA== dependencies: - "@babel/core" "^7.23.3" - "@babel/generator" "^7.23.3" + "@babel/core" "^7.25.9" + "@babel/generator" "^7.25.9" "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-transform-runtime" "^7.22.9" - "@babel/preset-env" "^7.22.9" - "@babel/preset-react" "^7.22.5" - "@babel/preset-typescript" "^7.22.5" - "@babel/runtime" "^7.22.6" - "@babel/runtime-corejs3" "^7.22.6" - "@babel/traverse" "^7.22.8" - "@docusaurus/cssnano-preset" "3.4.0" - "@docusaurus/logger" "3.4.0" - "@docusaurus/mdx-loader" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-common" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" - autoprefixer "^10.4.14" - babel-loader "^9.1.3" + "@babel/plugin-transform-runtime" "^7.25.9" + "@babel/preset-env" "^7.25.9" + "@babel/preset-react" "^7.25.9" + "@babel/preset-typescript" "^7.25.9" + "@babel/runtime" "^7.25.9" + "@babel/runtime-corejs3" "^7.25.9" + "@babel/traverse" "^7.25.9" + "@docusaurus/logger" "3.8.0" + "@docusaurus/utils" "3.8.0" babel-plugin-dynamic-import-node "^2.3.3" - boxen "^6.2.1" - chalk "^4.1.2" - chokidar "^3.5.3" + fs-extra "^11.1.1" + tslib "^2.6.0" + +"@docusaurus/bundler@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.8.0.tgz#386f54dca594d81bac6b617c71822e0808d6e2f6" + integrity sha512-Rq4Z/MSeAHjVzBLirLeMcjLIAQy92pF1OI+2rmt18fSlMARfTGLWRE8Vb+ljQPTOSfJxwDYSzsK6i7XloD2rNA== + dependencies: + "@babel/core" "^7.25.9" + "@docusaurus/babel" "3.8.0" + "@docusaurus/cssnano-preset" "3.8.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" + babel-loader "^9.2.1" clean-css "^5.3.2" - cli-table3 "^0.6.3" - combine-promises "^1.1.0" - commander "^5.1.0" copy-webpack-plugin "^11.0.0" - core-js "^3.31.1" css-loader "^6.8.1" css-minimizer-webpack-plugin "^5.0.1" cssnano "^6.1.2" - del "^6.1.1" + file-loader "^6.2.0" + html-minifier-terser "^7.2.0" + mini-css-extract-plugin "^2.9.1" + null-loader "^4.0.1" + postcss "^8.4.26" + postcss-loader "^7.3.3" + postcss-preset-env "^10.1.0" + terser-webpack-plugin "^5.3.9" + tslib "^2.6.0" + url-loader "^4.1.1" + webpack "^5.95.0" + webpackbar "^6.0.1" + +"@docusaurus/core@3.8.0", "@docusaurus/core@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.8.0.tgz#79d5e1084415c8834a8a5cb87162ca13f52fe147" + integrity sha512-c7u6zFELmSGPEP9WSubhVDjgnpiHgDqMh1qVdCB7rTflh4Jx0msTYmMiO91Ez0KtHj4sIsDsASnjwfJ2IZp3Vw== + dependencies: + "@docusaurus/babel" "3.8.0" + "@docusaurus/bundler" "3.8.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/mdx-loader" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-common" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" + boxen "^6.2.1" + chalk "^4.1.2" + chokidar "^3.5.3" + cli-table3 "^0.6.3" + combine-promises "^1.1.0" + commander "^5.1.0" + core-js "^3.31.1" detect-port "^1.5.1" escape-html "^1.0.3" eta "^2.2.0" eval "^0.1.8" - file-loader "^6.2.0" + execa "5.1.1" fs-extra "^11.1.1" - html-minifier-terser "^7.2.0" html-tags "^3.3.1" - html-webpack-plugin "^5.5.3" + html-webpack-plugin "^5.6.0" leven "^3.1.0" lodash "^4.17.21" - mini-css-extract-plugin "^2.7.6" + open "^8.4.0" p-map "^4.0.0" - postcss "^8.4.26" - postcss-loader "^7.3.3" prompts "^2.4.2" - react-dev-utils "^12.0.1" - react-helmet-async "^1.3.0" + react-helmet-async "npm:@slorber/react-helmet-async@1.3.0" react-loadable "npm:@docusaurus/react-loadable@6.0.0" react-loadable-ssr-addon-v5-slorber "^1.0.1" react-router "^5.3.4" react-router-config "^5.1.1" react-router-dom "^5.3.4" - rtl-detect "^1.0.4" semver "^7.5.4" - serve-handler "^6.1.5" - shelljs "^0.8.5" - terser-webpack-plugin "^5.3.9" + serve-handler "^6.1.6" + tinypool "^1.0.2" tslib "^2.6.0" update-notifier "^6.0.2" - url-loader "^4.1.1" - webpack "^5.88.1" - webpack-bundle-analyzer "^4.9.0" - webpack-dev-server "^4.15.1" - webpack-merge "^5.9.0" - webpackbar "^5.0.2" + webpack "^5.95.0" + webpack-bundle-analyzer "^4.10.2" + webpack-dev-server "^4.15.2" + webpack-merge "^6.0.1" -"@docusaurus/cssnano-preset@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.4.0.tgz#dc7922b3bbeabcefc9b60d0161680d81cf72c368" - integrity sha512-qwLFSz6v/pZHy/UP32IrprmH5ORce86BGtN0eBtG75PpzQJAzp9gefspox+s8IEOr0oZKuQ/nhzZ3xwyc3jYJQ== +"@docusaurus/cssnano-preset@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.0.tgz#a70f19e2995be2299f5ef9c3da3e5d4d5c14bff2" + integrity sha512-UJ4hAS2T0R4WNy+phwVff2Q0L5+RXW9cwlH6AEphHR5qw3m/yacfWcSK7ort2pMMbDn8uGrD38BTm4oLkuuNoQ== dependencies: cssnano-preset-advanced "^6.1.2" postcss "^8.4.38" postcss-sort-media-queries "^5.2.0" tslib "^2.6.0" -"@docusaurus/logger@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.4.0.tgz#8b0ac05c7f3dac2009066e2f964dee8209a77403" - integrity sha512-bZwkX+9SJ8lB9kVRkXw+xvHYSMGG4bpYHKGXeXFvyVc79NMeeBSGgzd4TQLHH+DYeOJoCdl8flrFJVxlZ0wo/Q== +"@docusaurus/faster@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/faster/-/faster-3.8.0.tgz#2814c5ea4f19e10a6cebf9296b6f15f8a621bf61" + integrity sha512-v9+8rT2gw/4zIRBwc4fIVhrTH/yFVDQgJgyYZjqr3fgojOypdQCOwkN6Z8dOwTei4/zo+b/zDPB4x1UvghJZRg== + dependencies: + "@docusaurus/types" "3.8.0" + "@rspack/core" "^1.3.10" + "@swc/core" "^1.7.39" + "@swc/html" "^1.7.39" + browserslist "^4.24.2" + lightningcss "^1.27.0" + swc-loader "^0.2.6" + tslib "^2.6.0" + webpack "^5.95.0" + +"@docusaurus/logger@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.8.0.tgz#c1abbb084a8058dc0047d57070fb9cd0241a679d" + integrity sha512-7eEMaFIam5Q+v8XwGqF/n0ZoCld4hV4eCCgQkfcN9Mq5inoZa6PHHW9Wu6lmgzoK5Kx3keEeABcO2SxwraoPDQ== dependencies: chalk "^4.1.2" tslib "^2.6.0" -"@docusaurus/mdx-loader@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.4.0.tgz#483d7ab57928fdbb5c8bd1678098721a930fc5f6" - integrity sha512-kSSbrrk4nTjf4d+wtBA9H+FGauf2gCax89kV8SUSJu3qaTdSIKdWERlngsiHaCFgZ7laTJ8a67UFf+xlFPtuTw== +"@docusaurus/mdx-loader@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.8.0.tgz#2b225cd2b1159cc49b10b1cac63a927a8368274b" + integrity sha512-mDPSzssRnpjSdCGuv7z2EIAnPS1MHuZGTaRLwPn4oQwszu4afjWZ/60sfKjTnjBjI8Vl4OgJl2vMmfmiNDX4Ng== dependencies: - "@docusaurus/logger" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" "@mdx-js/mdx" "^3.0.0" "@slorber/remark-comment" "^1.0.0" escape-html "^1.0.3" estree-util-value-to-estree "^3.0.1" file-loader "^6.2.0" fs-extra "^11.1.1" - image-size "^1.0.2" + image-size "^2.0.2" mdast-util-mdx "^3.0.0" mdast-util-to-string "^4.0.0" rehype-raw "^7.0.0" @@ -1469,176 +2711,206 @@ vfile "^6.0.1" webpack "^5.88.1" -"@docusaurus/module-type-aliases@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.4.0.tgz#2653bde58fc1aa3dbc626a6c08cfb63a37ae1bb8" - integrity sha512-A1AyS8WF5Bkjnb8s+guTDuYmUiwJzNrtchebBHpc0gz0PyHJNMaybUlSrmJjHVcGrya0LKI4YcR3lBDQfXRYLw== +"@docusaurus/module-type-aliases@3.8.0", "@docusaurus/module-type-aliases@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.0.tgz#e487052c372538c5dcf2200999e13f328fa5ffaa" + integrity sha512-/uMb4Ipt5J/QnD13MpnoC/A4EYAe6DKNWqTWLlGrqsPJwJv73vSwkA25xnYunwfqWk0FlUQfGv/Swdh5eCCg7g== dependencies: - "@docusaurus/types" "3.4.0" + "@docusaurus/types" "3.8.0" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" "@types/react-router-dom" "*" - react-helmet-async "*" + react-helmet-async "npm:@slorber/react-helmet-async@1.3.0" react-loadable "npm:@docusaurus/react-loadable@6.0.0" -"@docusaurus/plugin-content-blog@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.4.0.tgz#6373632fdbababbda73a13c4a08f907d7de8f007" - integrity sha512-vv6ZAj78ibR5Jh7XBUT4ndIjmlAxkijM3Sx5MAAzC1gyv0vupDQNhzuFg1USQmQVj3P5I6bquk12etPV3LJ+Xw== +"@docusaurus/plugin-content-blog@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.0.tgz#0c200b1fb821e09e9e975c45255e5ddfab06c392" + integrity sha512-0SlOTd9R55WEr1GgIXu+hhTT0hzARYx3zIScA5IzpdekZQesI/hKEa5LPHBd415fLkWMjdD59TaW/3qQKpJ0Lg== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/logger" "3.4.0" - "@docusaurus/mdx-loader" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-common" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" - cheerio "^1.0.0-rc.12" + "@docusaurus/core" "3.8.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/mdx-loader" "3.8.0" + "@docusaurus/theme-common" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-common" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" + cheerio "1.0.0-rc.12" feed "^4.2.2" fs-extra "^11.1.1" lodash "^4.17.21" - reading-time "^1.5.0" + schema-dts "^1.1.2" srcset "^4.0.0" tslib "^2.6.0" unist-util-visit "^5.0.0" utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-docs@3.4.0", "@docusaurus/plugin-content-docs@^3.0.1": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.4.0.tgz#3088973f72169a2a6d533afccec7153c8720d332" - integrity sha512-HkUCZffhBo7ocYheD9oZvMcDloRnGhBMOZRyVcAQRFmZPmNqSyISlXA1tQCIxW+r478fty97XXAGjNYzBjpCsg== +"@docusaurus/plugin-content-docs@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.0.tgz#6aedb1261da1f0c8c2fa11cfaa6df4577a9b7826" + integrity sha512-fRDMFLbUN6eVRXcjP8s3Y7HpAt9pzPYh1F/7KKXOCxvJhjjCtbon4VJW0WndEPInVz4t8QUXn5QZkU2tGVCE2g== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/logger" "3.4.0" - "@docusaurus/mdx-loader" "3.4.0" - "@docusaurus/module-type-aliases" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-common" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/mdx-loader" "3.8.0" + "@docusaurus/module-type-aliases" "3.8.0" + "@docusaurus/theme-common" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-common" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" "@types/react-router-config" "^5.0.7" combine-promises "^1.1.0" fs-extra "^11.1.1" js-yaml "^4.1.0" lodash "^4.17.21" + schema-dts "^1.1.2" tslib "^2.6.0" utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-pages@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.4.0.tgz#1846172ca0355c7d32a67ef8377750ce02bbb8ad" - integrity sha512-h2+VN/0JjpR8fIkDEAoadNjfR3oLzB+v1qSXbIAKjQ46JAHx3X22n9nqS+BWSQnTnp1AjkjSvZyJMekmcwxzxg== +"@docusaurus/plugin-content-pages@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.0.tgz#2db5f990872684c621665d0d0d8d9b5831fd2999" + integrity sha512-39EDx2y1GA0Pxfion5tQZLNJxL4gq6susd1xzetVBjVIQtwpCdyloOfQBAgX0FylqQxfJrYqL0DIUuq7rd7uBw== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/mdx-loader" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/mdx-loader" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" fs-extra "^11.1.1" tslib "^2.6.0" webpack "^5.88.1" -"@docusaurus/plugin-debug@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.4.0.tgz#74e4ec5686fa314c26f3ac150bacadbba7f06948" - integrity sha512-uV7FDUNXGyDSD3PwUaf5YijX91T5/H9SX4ErEcshzwgzWwBtK37nUWPU3ZLJfeTavX3fycTOqk9TglpOLaWkCg== +"@docusaurus/plugin-css-cascade-layers@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.0.tgz#a0741ae32917a88ce7ce76b6f472495fa4bf576d" + integrity sha512-/VBTNymPIxQB8oA3ZQ4GFFRYdH4ZxDRRBECxyjRyv486mfUPXfcdk+im4S5mKWa6EK2JzBz95IH/Wu0qQgJ5yQ== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" + tslib "^2.6.0" + +"@docusaurus/plugin-debug@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.8.0.tgz#297c159ae99924e60042426d2ad6ee0d5e9126b3" + integrity sha512-teonJvJsDB9o2OnG6ifbhblg/PXzZvpUKHFgD8dOL1UJ58u0lk8o0ZOkvaYEBa9nDgqzoWrRk9w+e3qaG2mOhQ== + dependencies: + "@docusaurus/core" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" fs-extra "^11.1.1" - react-json-view-lite "^1.2.0" + react-json-view-lite "^2.3.0" tslib "^2.6.0" -"@docusaurus/plugin-google-analytics@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.4.0.tgz#5f59fc25329a59decc231936f6f9fb5663da3c55" - integrity sha512-mCArluxEGi3cmYHqsgpGGt3IyLCrFBxPsxNZ56Mpur0xSlInnIHoeLDH7FvVVcPJRPSQ9/MfRqLsainRw+BojA== +"@docusaurus/plugin-google-analytics@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.0.tgz#fb97097af331beb13553a384081dc83607539b31" + integrity sha512-aKKa7Q8+3xRSRESipNvlFgNp3FNPELKhuo48Cg/svQbGNwidSHbZT03JqbW4cBaQnyyVchO1ttk+kJ5VC9Gx0w== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" tslib "^2.6.0" -"@docusaurus/plugin-google-gtag@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.4.0.tgz#42489ac5fe1c83b5523ceedd5ef74f9aa8bc251b" - integrity sha512-Dsgg6PLAqzZw5wZ4QjUYc8Z2KqJqXxHxq3vIoyoBWiLEEfigIs7wHR+oiWUQy3Zk9MIk6JTYj7tMoQU0Jm3nqA== +"@docusaurus/plugin-google-gtag@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.0.tgz#b5a60006c28ac582859a469fb92e53d383b0a055" + integrity sha512-ugQYMGF4BjbAW/JIBtVcp+9eZEgT9HRdvdcDudl5rywNPBA0lct+lXMG3r17s02rrhInMpjMahN3Yc9Cb3H5/g== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" "@types/gtag.js" "^0.0.12" tslib "^2.6.0" -"@docusaurus/plugin-google-tag-manager@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.4.0.tgz#cebb03a5ffa1e70b37d95601442babea251329ff" - integrity sha512-O9tX1BTwxIhgXpOLpFDueYA9DWk69WCbDRrjYoMQtFHSkTyE7RhNgyjSPREUWJb9i+YUg3OrsvrBYRl64FCPCQ== +"@docusaurus/plugin-google-tag-manager@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.0.tgz#612aa63e161fb273bf7db2591034c0142951727d" + integrity sha512-9juRWxbwZD3SV02Jd9QB6yeN7eu+7T4zB0bvJLcVQwi+am51wAxn2CwbdL0YCCX+9OfiXbADE8D8Q65Hbopu/w== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" tslib "^2.6.0" -"@docusaurus/plugin-sitemap@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.4.0.tgz#b091d64d1e3c6c872050189999580187537bcbc6" - integrity sha512-+0VDvx9SmNrFNgwPoeoCha+tRoAjopwT0+pYO1xAbyLcewXSemq+eLxEa46Q1/aoOaJQ0qqHELuQM7iS2gp33Q== +"@docusaurus/plugin-sitemap@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.0.tgz#a39e3b5aa2f059aba0052ed11a6b4fbf78ac0dad" + integrity sha512-fGpOIyJvNiuAb90nSJ2Gfy/hUOaDu6826e5w5UxPmbpCIc7KlBHNAZ5g4L4ZuHhc4hdfq4mzVBsQSnne+8Ze1g== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/logger" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-common" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-common" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" fs-extra "^11.1.1" sitemap "^7.1.1" tslib "^2.6.0" -"@docusaurus/preset-classic@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.4.0.tgz#6082a32fbb465b0cb2c2a50ebfc277cff2c0f139" - integrity sha512-Ohj6KB7siKqZaQhNJVMBBUzT3Nnp6eTKqO+FXO3qu/n1hJl3YLwVKTWBg28LF7MWrKu46UuYavwMRxud0VyqHg== +"@docusaurus/plugin-svgr@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.0.tgz#6d2d43f14b32b4bb2dd8dc87a70c6e78754c1e85" + integrity sha512-kEDyry+4OMz6BWLG/lEqrNsL/w818bywK70N1gytViw4m9iAmoxCUT7Ri9Dgs7xUdzCHJ3OujolEmD88Wy44OA== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/plugin-content-blog" "3.4.0" - "@docusaurus/plugin-content-docs" "3.4.0" - "@docusaurus/plugin-content-pages" "3.4.0" - "@docusaurus/plugin-debug" "3.4.0" - "@docusaurus/plugin-google-analytics" "3.4.0" - "@docusaurus/plugin-google-gtag" "3.4.0" - "@docusaurus/plugin-google-tag-manager" "3.4.0" - "@docusaurus/plugin-sitemap" "3.4.0" - "@docusaurus/theme-classic" "3.4.0" - "@docusaurus/theme-common" "3.4.0" - "@docusaurus/theme-search-algolia" "3.4.0" - "@docusaurus/types" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" + "@svgr/core" "8.1.0" + "@svgr/webpack" "^8.1.0" + tslib "^2.6.0" + webpack "^5.88.1" -"@docusaurus/theme-classic@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.4.0.tgz#1b0f48edec3e3ec8927843554b9f11e5927b0e52" - integrity sha512-0IPtmxsBYv2adr1GnZRdMkEQt1YW6tpzrUPj02YxNpvJ5+ju4E13J5tB4nfdaen/tfR1hmpSPlTFPvTf4kwy8Q== +"@docusaurus/preset-classic@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.8.0.tgz#ac8bc17e3b7b443d8a24f2f1da0c0be396950fef" + integrity sha512-qOu6tQDOWv+rpTlKu+eJATCJVGnABpRCPuqf7LbEaQ1mNY//N/P8cHQwkpAU+aweQfarcZ0XfwCqRHJfjeSV/g== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/mdx-loader" "3.4.0" - "@docusaurus/module-type-aliases" "3.4.0" - "@docusaurus/plugin-content-blog" "3.4.0" - "@docusaurus/plugin-content-docs" "3.4.0" - "@docusaurus/plugin-content-pages" "3.4.0" - "@docusaurus/theme-common" "3.4.0" - "@docusaurus/theme-translations" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-common" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/plugin-content-blog" "3.8.0" + "@docusaurus/plugin-content-docs" "3.8.0" + "@docusaurus/plugin-content-pages" "3.8.0" + "@docusaurus/plugin-css-cascade-layers" "3.8.0" + "@docusaurus/plugin-debug" "3.8.0" + "@docusaurus/plugin-google-analytics" "3.8.0" + "@docusaurus/plugin-google-gtag" "3.8.0" + "@docusaurus/plugin-google-tag-manager" "3.8.0" + "@docusaurus/plugin-sitemap" "3.8.0" + "@docusaurus/plugin-svgr" "3.8.0" + "@docusaurus/theme-classic" "3.8.0" + "@docusaurus/theme-common" "3.8.0" + "@docusaurus/theme-search-algolia" "3.8.0" + "@docusaurus/types" "3.8.0" + +"@docusaurus/theme-classic@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.8.0.tgz#6d44fb801b86a7c7af01cda0325af1a3300b3ac2" + integrity sha512-nQWFiD5ZjoT76OaELt2n33P3WVuuCz8Dt5KFRP2fCBo2r9JCLsp2GJjZpnaG24LZ5/arRjv4VqWKgpK0/YLt7g== + dependencies: + "@docusaurus/core" "3.8.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/mdx-loader" "3.8.0" + "@docusaurus/module-type-aliases" "3.8.0" + "@docusaurus/plugin-content-blog" "3.8.0" + "@docusaurus/plugin-content-docs" "3.8.0" + "@docusaurus/plugin-content-pages" "3.8.0" + "@docusaurus/theme-common" "3.8.0" + "@docusaurus/theme-translations" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-common" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" "@mdx-js/react" "^3.0.0" clsx "^2.0.0" copy-text-to-clipboard "^3.2.0" - infima "0.2.0-alpha.43" + infima "0.2.0-alpha.45" lodash "^4.17.21" nprogress "^0.2.0" postcss "^8.4.26" @@ -1649,18 +2921,15 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-common@3.4.0", "@docusaurus/theme-common@^3.0.1": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.4.0.tgz#01f2b728de6cb57f6443f52fc30675cf12a5d49f" - integrity sha512-0A27alXuv7ZdCg28oPE8nH/Iz73/IUejVaCazqu9elS4ypjiLhK3KfzdSQBnL/g7YfHSlymZKdiOHEo8fJ0qMA== +"@docusaurus/theme-common@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.8.0.tgz#102c385c3d1d3b7a6b52d1911c7e88c38d9a977e" + integrity sha512-YqV2vAWpXGLA+A3PMLrOMtqgTHJLDcT+1Caa6RF7N4/IWgrevy5diY8oIHFkXR/eybjcrFFjUPrHif8gSGs3Tw== dependencies: - "@docusaurus/mdx-loader" "3.4.0" - "@docusaurus/module-type-aliases" "3.4.0" - "@docusaurus/plugin-content-blog" "3.4.0" - "@docusaurus/plugin-content-docs" "3.4.0" - "@docusaurus/plugin-content-pages" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-common" "3.4.0" + "@docusaurus/mdx-loader" "3.8.0" + "@docusaurus/module-type-aliases" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-common" "3.8.0" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" @@ -1670,34 +2939,34 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-mermaid@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.4.0.tgz#ef1d2231d0858767f67538b4fafd7d0ce2a3e845" - integrity sha512-3w5QW0HEZ2O6x2w6lU3ZvOe1gNXP2HIoKDMJBil1VmLBc9PmpAG17VmfhI/p3L2etNmOiVs5GgniUqvn8AFEGQ== +"@docusaurus/theme-mermaid@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.8.0.tgz#f0720ec89fd386870f30978ba984b1b126ca92a5" + integrity sha512-ou0NJM37p4xrVuFaZp8qFe5Z/qBq9LuyRTP4KKRa0u2J3zC4f3saBJDgc56FyvvN1OsmU0189KGEPUjTr6hFxg== dependencies: - "@docusaurus/core" "3.4.0" - "@docusaurus/module-type-aliases" "3.4.0" - "@docusaurus/theme-common" "3.4.0" - "@docusaurus/types" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" - mermaid "^10.4.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/module-type-aliases" "3.8.0" + "@docusaurus/theme-common" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" + mermaid ">=11.6.0" tslib "^2.6.0" -"@docusaurus/theme-search-algolia@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.4.0.tgz#c499bad71d668df0d0f15b0e5e33e2fc4e330fcc" - integrity sha512-aiHFx7OCw4Wck1z6IoShVdUWIjntC8FHCw9c5dR8r3q4Ynh+zkS8y2eFFunN/DL6RXPzpnvKCg3vhLQYJDmT9Q== +"@docusaurus/theme-search-algolia@3.8.0", "@docusaurus/theme-search-algolia@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.0.tgz#21c2f18e07a73d13ca3b44fcf0ae9aac33bef60f" + integrity sha512-GBZ5UOcPgiu6nUw153+0+PNWvFKweSnvKIL6Rp04H9olKb475jfKjAwCCtju5D2xs5qXHvCMvzWOg5o9f6DtuQ== dependencies: - "@docsearch/react" "^3.5.2" - "@docusaurus/core" "3.4.0" - "@docusaurus/logger" "3.4.0" - "@docusaurus/plugin-content-docs" "3.4.0" - "@docusaurus/theme-common" "3.4.0" - "@docusaurus/theme-translations" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-validation" "3.4.0" - algoliasearch "^4.18.0" - algoliasearch-helper "^3.13.3" + "@docsearch/react" "^3.9.0" + "@docusaurus/core" "3.8.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/plugin-content-docs" "3.8.0" + "@docusaurus/theme-common" "3.8.0" + "@docusaurus/theme-translations" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-validation" "3.8.0" + algoliasearch "^5.17.1" + algoliasearch-helper "^3.22.6" clsx "^2.0.0" eta "^2.2.0" fs-extra "^11.1.1" @@ -1705,15 +2974,30 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-translations@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.4.0.tgz#e6355d01352886c67e38e848b2542582ea3070af" - integrity sha512-zSxCSpmQCCdQU5Q4CnX/ID8CSUUI3fvmq4hU/GNP/XoAWtXo9SAVnM3TzpU8Gb//H3WCsT8mJcTfyOk3d9ftNg== +"@docusaurus/theme-translations@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.8.0.tgz#deb64dccab74361624c3cb352a4949a7ac868c74" + integrity sha512-1DTy/snHicgkCkryWq54fZvsAglTdjTx4qjOXgqnXJ+DIty1B+aPQrAVUu8LiM+6BiILfmNxYsxhKTj+BS3PZg== dependencies: fs-extra "^11.1.1" tslib "^2.6.0" -"@docusaurus/types@3.4.0", "@docusaurus/types@^3.0.0": +"@docusaurus/types@3.8.0", "@docusaurus/types@^3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.8.0.tgz#f6cd31c4e3e392e0270b8137d7fe4365ea7a022e" + integrity sha512-RDEClpwNxZq02c+JlaKLWoS13qwWhjcNsi2wG1UpzmEnuti/z1Wx4SGpqbUqRPNSd8QWWePR8Cb7DvG0VN/TtA== + dependencies: + "@mdx-js/mdx" "^3.0.0" + "@types/history" "^4.7.11" + "@types/react" "*" + commander "^5.1.0" + joi "^17.9.2" + react-helmet-async "npm:@slorber/react-helmet-async@1.3.0" + utility-types "^3.10.0" + webpack "^5.95.0" + webpack-merge "^5.9.0" + +"@docusaurus/types@^3.0.0": version "3.4.0" resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.4.0.tgz#237c3f737e9db3f7c1a5935a3ef48d6eadde8292" integrity sha512-4jcDO8kXi5Cf9TcyikB/yKmz14f2RZ2qTRerbHAsS+5InE9ZgSLBNLsewtFTcTOXSVcbU3FoGOzcNWAmU1TR0A== @@ -1728,36 +3012,38 @@ webpack "^5.88.1" webpack-merge "^5.9.0" -"@docusaurus/utils-common@3.4.0": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.4.0.tgz#2a43fefd35b85ab9fcc6833187e66c15f8bfbbc6" - integrity sha512-NVx54Wr4rCEKsjOH5QEVvxIqVvm+9kh7q8aYTU5WzUU9/Hctd6aTrcZ3G0Id4zYJ+AeaG5K5qHA4CY5Kcm2iyQ== +"@docusaurus/utils-common@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.8.0.tgz#2b1a6b1ec4a7fac62f1898d523d42f8cc4a8258f" + integrity sha512-3TGF+wVTGgQ3pAc9+5jVchES4uXUAhAt9pwv7uws4mVOxL4alvU3ue/EZ+R4XuGk94pDy7CNXjRXpPjlfZXQfw== dependencies: + "@docusaurus/types" "3.8.0" tslib "^2.6.0" -"@docusaurus/utils-validation@3.4.0", "@docusaurus/utils-validation@^3.0.1": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.4.0.tgz#0176f6e503ff45f4390ec2ecb69550f55e0b5eb7" - integrity sha512-hYQ9fM+AXYVTWxJOT1EuNaRnrR2WGpRdLDQG07O8UOpsvCPWUVOeo26Rbm0JWY2sGLfzAb+tvJ62yF+8F+TV0g== +"@docusaurus/utils-validation@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.8.0.tgz#aa02e9d998e20998fbcaacd94873878bc3b9a4cd" + integrity sha512-MrnEbkigr54HkdFeg8e4FKc4EF+E9dlVwsY3XQZsNkbv3MKZnbHQ5LsNJDIKDROFe8PBf5C4qCAg5TPBpsjrjg== dependencies: - "@docusaurus/logger" "3.4.0" - "@docusaurus/utils" "3.4.0" - "@docusaurus/utils-common" "3.4.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/utils" "3.8.0" + "@docusaurus/utils-common" "3.8.0" fs-extra "^11.2.0" joi "^17.9.2" js-yaml "^4.1.0" lodash "^4.17.21" tslib "^2.6.0" -"@docusaurus/utils@3.4.0", "@docusaurus/utils@^3.0.1": - version "3.4.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.4.0.tgz#c508e20627b7a55e2b541e4a28c95e0637d6a204" - integrity sha512-fRwnu3L3nnWaXOgs88BVBmG1yGjcQqZNHG+vInhEa2Sz2oQB+ZjbEMO5Rh9ePFpZ0YDiDUhpaVjwmS+AU2F14g== +"@docusaurus/utils@3.8.0": + version "3.8.0" + resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.8.0.tgz#92bad89d2a11f5f246196af153093b12cd79f9ac" + integrity sha512-2wvtG28ALCN/A1WCSLxPASFBFzXCnP0YKCAFIPcvEb6imNu1wg7ni/Svcp71b3Z2FaOFFIv4Hq+j4gD7gA0yfQ== dependencies: - "@docusaurus/logger" "3.4.0" - "@docusaurus/utils-common" "3.4.0" - "@svgr/webpack" "^8.1.0" + "@docusaurus/logger" "3.8.0" + "@docusaurus/types" "3.8.0" + "@docusaurus/utils-common" "3.8.0" escape-string-regexp "^4.0.0" + execa "5.1.1" file-loader "^6.2.0" fs-extra "^11.1.1" github-slugger "^1.5.0" @@ -1767,9 +3053,9 @@ js-yaml "^4.1.0" lodash "^4.17.21" micromatch "^4.0.5" + p-queue "^6.6.2" prompts "^2.4.2" resolve-pathname "^3.0.0" - shelljs "^0.8.5" tslib "^2.6.0" url-loader "^4.1.1" utility-types "^3.10.0" @@ -1815,6 +3101,25 @@ resolved "https://registry.yarnpkg.com/@hookform/error-message/-/error-message-2.0.1.tgz#6a37419106e13664ad6a29c9dae699ae6cd276b8" integrity sha512-U410sAr92xgxT1idlu9WWOVjndxLdgPUHEB8Schr27C9eh7/xUnITWpCMF93s+lGiG++D4JnbSnrb5A21AdSNg== +"@iconify/types@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" + integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== + +"@iconify/utils@^2.1.33": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@iconify/utils/-/utils-2.3.0.tgz#1bbbf8c477ebe9a7cacaea78b1b7e8937f9cbfba" + integrity sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA== + dependencies: + "@antfu/install-pkg" "^1.0.0" + "@antfu/utils" "^8.1.0" + "@iconify/types" "^2.0.0" + debug "^4.4.0" + globals "^15.14.0" + kolorist "^1.8.0" + local-pkg "^1.0.0" + mlly "^1.7.4" + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -1932,6 +3237,56 @@ dependencies: "@types/mdx" "^2.0.0" +"@mermaid-js/parser@^0.4.0": + version "0.4.0" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.4.0.tgz#c1de1f5669f8fcbd0d0c9d124927d36ddc00d8a6" + integrity sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA== + dependencies: + langium "3.3.1" + +"@module-federation/error-codes@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@module-federation/error-codes/-/error-codes-0.14.0.tgz#d54581bfb998ce9ace4cb33f8795c644e461bfeb" + integrity sha512-GGk+EoeSACJikZZyShnLshtq9E2eCrDWbRiB4QAFXCX4oYmGgFfzXlx59vMNwqTKPJWxkEGnPYacJMcr2YYjag== + +"@module-federation/runtime-core@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@module-federation/runtime-core/-/runtime-core-0.14.0.tgz#a00a3666cc25a8bb822a36552c631e6e3f7326cc" + integrity sha512-fGE1Ro55zIFDp/CxQuRhKQ1pJvG7P0qvRm2N+4i8z++2bgDjcxnCKUqDJ8lLD+JfJQvUJf0tuSsJPgevzueD4g== + dependencies: + "@module-federation/error-codes" "0.14.0" + "@module-federation/sdk" "0.14.0" + +"@module-federation/runtime-tools@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.14.0.tgz#f5b5f3d19605b6d7c90ed278dc00a5400f1aa49d" + integrity sha512-y/YN0c2DKsLETE+4EEbmYWjqF9G6ZwgZoDIPkaQ9p0pQu0V4YxzWfQagFFxR0RigYGuhJKmSU/rtNoHq+qF8jg== + dependencies: + "@module-federation/runtime" "0.14.0" + "@module-federation/webpack-bundler-runtime" "0.14.0" + +"@module-federation/runtime@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.14.0.tgz#e04012d2d928275fd00525904c0b4f8be514dc70" + integrity sha512-kR3cyHw/Y64SEa7mh4CHXOEQYY32LKLK75kJOmBroLNLO7/W01hMNAvGBYTedS7hWpVuefPk1aFZioy3q2VLdQ== + dependencies: + "@module-federation/error-codes" "0.14.0" + "@module-federation/runtime-core" "0.14.0" + "@module-federation/sdk" "0.14.0" + +"@module-federation/sdk@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.14.0.tgz#efa38341b7601f58967397cc630068f69691b931" + integrity sha512-lg/OWRsh18hsyTCamOOhEX546vbDiA2O4OggTxxH2wTGr156N6DdELGQlYIKfRdU/0StgtQS81Goc0BgDZlx9A== + +"@module-federation/webpack-bundler-runtime@0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.14.0.tgz#21a82505f95fdb3cb202786f8dc20611b4d7f93c" + integrity sha512-POWS6cKBicAAQ3DNY5X7XEUSfOfUsRaBNxbuwEfSGlrkTE9UcWheO06QP2ndHi8tHQuUKcIHi2navhPkJ+k5xg== + dependencies: + "@module-federation/runtime" "0.14.0" + "@module-federation/sdk" "0.14.0" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -1953,6 +3308,95 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@parcel/watcher-android-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz#507f836d7e2042f798c7d07ad19c3546f9848ac1" + integrity sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA== + +"@parcel/watcher-darwin-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz#3d26dce38de6590ef79c47ec2c55793c06ad4f67" + integrity sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw== + +"@parcel/watcher-darwin-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz#99f3af3869069ccf774e4ddfccf7e64fd2311ef8" + integrity sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg== + +"@parcel/watcher-freebsd-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz#14d6857741a9f51dfe51d5b08b7c8afdbc73ad9b" + integrity sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ== + +"@parcel/watcher-linux-arm-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz#43c3246d6892381db473bb4f663229ad20b609a1" + integrity sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA== + +"@parcel/watcher-linux-arm-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz#663750f7090bb6278d2210de643eb8a3f780d08e" + integrity sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q== + +"@parcel/watcher-linux-arm64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz#ba60e1f56977f7e47cd7e31ad65d15fdcbd07e30" + integrity sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w== + +"@parcel/watcher-linux-arm64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz#f7fbcdff2f04c526f96eac01f97419a6a99855d2" + integrity sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg== + +"@parcel/watcher-linux-x64-glibc@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz#4d2ea0f633eb1917d83d483392ce6181b6a92e4e" + integrity sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A== + +"@parcel/watcher-linux-x64-musl@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz#277b346b05db54f55657301dd77bdf99d63606ee" + integrity sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg== + +"@parcel/watcher-win32-arm64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz#7e9e02a26784d47503de1d10e8eab6cceb524243" + integrity sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw== + +"@parcel/watcher-win32-ia32@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz#2d0f94fa59a873cdc584bf7f6b1dc628ddf976e6" + integrity sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ== + +"@parcel/watcher-win32-x64@2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz#ae52693259664ba6f2228fa61d7ee44b64ea0947" + integrity sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA== + +"@parcel/watcher@^2.4.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.1.tgz#342507a9cfaaf172479a882309def1e991fb1200" + integrity sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg== + dependencies: + detect-libc "^1.0.3" + is-glob "^4.0.3" + micromatch "^4.0.5" + node-addon-api "^7.0.0" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.1" + "@parcel/watcher-darwin-arm64" "2.5.1" + "@parcel/watcher-darwin-x64" "2.5.1" + "@parcel/watcher-freebsd-x64" "2.5.1" + "@parcel/watcher-linux-arm-glibc" "2.5.1" + "@parcel/watcher-linux-arm-musl" "2.5.1" + "@parcel/watcher-linux-arm64-glibc" "2.5.1" + "@parcel/watcher-linux-arm64-musl" "2.5.1" + "@parcel/watcher-linux-x64-glibc" "2.5.1" + "@parcel/watcher-linux-x64-musl" "2.5.1" + "@parcel/watcher-win32-arm64" "2.5.1" + "@parcel/watcher-win32-ia32" "2.5.1" + "@parcel/watcher-win32-x64" "2.5.1" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -2025,6 +3469,81 @@ redux-thunk "^2.4.2" reselect "^4.1.8" +"@rspack/binding-darwin-arm64@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.3.12.tgz#2e7cc00b813dcb155572908d956ab1d75d9747a5" + integrity sha512-8hKjVTBeWPqkMzFPNWIh72oU9O3vFy3e88wRjMPImDCXBiEYrKqGTTLd/J0SO+efdL3SBD1rX1IvdJpxCv6Yrw== + +"@rspack/binding-darwin-x64@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.3.12.tgz#cb148cc62658d74204621a695e1698cda82877f9" + integrity sha512-Sj4m+mCUxL7oCpdu7OmWT7fpBM7hywk5CM9RDc3D7StaBZbvNtNftafCrTZzTYKuZrKmemTh5SFzT5Tz7tf6GA== + +"@rspack/binding-linux-arm64-gnu@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.3.12.tgz#79820857dfbd3819e0026da507c271531c013d0c" + integrity sha512-7MuOxf3/Mhv4mgFdLTvgnt/J+VouNR65DEhorth+RZm3LEWojgoFEphSAMAvpvAOpYSS68Sw4SqsOZi719ia2w== + +"@rspack/binding-linux-arm64-musl@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.3.12.tgz#a794dfe8df6bf75af217597881592add6f6b046e" + integrity sha512-s6KKj20T9Z1bA8caIjU6EzJbwyDo1URNFgBAlafCT2UC6yX7flstDJJ38CxZacA9A2P24RuQK2/jPSZpWrTUFA== + +"@rspack/binding-linux-x64-gnu@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.3.12.tgz#a0e23831a1374d9039a4b29e927cef58a485085c" + integrity sha512-0w/sRREYbRgHgWvs2uMEJSLfvzbZkPHUg6CMcYQGNVK6axYRot6jPyKetyFYA9pR5fB5rsXegpnFaZaVrRIK2g== + +"@rspack/binding-linux-x64-musl@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.3.12.tgz#61620efda6a6805689890c130e6b38c1725baaf4" + integrity sha512-jEdxkPymkRxbijDRsBGdhopcbGXiXDg59lXqIRkVklqbDmZ/O6DHm7gImmlx5q9FoWbz0gqJuOKBz4JqWxjWVA== + +"@rspack/binding-win32-arm64-msvc@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.3.12.tgz#46d874df8bd5b84e82ea83480969f7e0293ccd82" + integrity sha512-ZRvUCb3TDLClAqcTsl/o9UdJf0B5CgzAxgdbnYJbldyuyMeTUB4jp20OfG55M3C2Nute2SNhu2bOOp9Se5Ongw== + +"@rspack/binding-win32-ia32-msvc@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.3.12.tgz#fec435e31e56f3d58b4fa52746643d1d593b2b89" + integrity sha512-1TKPjuXStPJr14f3ZHuv40Xc/87jUXx10pzVtrPnw+f3hckECHrbYU/fvbVzZyuXbsXtkXpYca6ygCDRJAoNeQ== + +"@rspack/binding-win32-x64-msvc@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.3.12.tgz#33b73cbab75920cf8a92e7245a794970f8508b6c" + integrity sha512-lCR0JfnYKpV+a6r2A2FdxyUKUS4tajePgpPJN5uXDgMGwrDtRqvx+d0BHhwjFudQVJq9VVbRaL89s2MQ6u+xYw== + +"@rspack/binding@1.3.12": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/binding/-/binding-1.3.12.tgz#0a8356fdbd89f08cda3e9bb8aff4ea781dfe972e" + integrity sha512-4Ic8lV0+LCBfTlH5aIOujIRWZOtgmG223zC4L3o8WY/+ESAgpdnK6lSSMfcYgRanYLAy3HOmFIp20jwskMpbAg== + optionalDependencies: + "@rspack/binding-darwin-arm64" "1.3.12" + "@rspack/binding-darwin-x64" "1.3.12" + "@rspack/binding-linux-arm64-gnu" "1.3.12" + "@rspack/binding-linux-arm64-musl" "1.3.12" + "@rspack/binding-linux-x64-gnu" "1.3.12" + "@rspack/binding-linux-x64-musl" "1.3.12" + "@rspack/binding-win32-arm64-msvc" "1.3.12" + "@rspack/binding-win32-ia32-msvc" "1.3.12" + "@rspack/binding-win32-x64-msvc" "1.3.12" + +"@rspack/core@^1.3.10": + version "1.3.12" + resolved "https://registry.yarnpkg.com/@rspack/core/-/core-1.3.12.tgz#68df0111cfac7e8f9dfa11a608ac8731181b5483" + integrity sha512-mAPmV4LPPRgxpouUrGmAE4kpF1NEWJGyM5coebsjK/zaCMSjw3mkdxiU2b5cO44oIi0Ifv5iGkvwbdrZOvMyFA== + dependencies: + "@module-federation/runtime-tools" "0.14.0" + "@rspack/binding" "1.3.12" + "@rspack/lite-tapable" "1.0.1" + caniuse-lite "^1.0.30001718" + +"@rspack/lite-tapable@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@rspack/lite-tapable/-/lite-tapable-1.0.1.tgz#d4540a5d28bd6177164bc0ba0bee4bdec0458591" + integrity sha512-VynGOEsVw2s8TAlLf/uESfrgfrq2+rcXB1muPJYBWbsm1Oa6r5qVQhjA5ggM6z/coYPrsVMgovl3Ff7Q7OCp1w== + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" @@ -2172,84 +3691,152 @@ "@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-svgo" "8.1.0" -"@swc/core-darwin-arm64@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.6.1.tgz#72d861fb7094b7a0004f4f300e2c5d4ea1549d9e" - integrity sha512-u6GdwOXsOEdNAdSI6nWq6G2BQw5HiSNIZVcBaH1iSvBnxZvWbnIKyDiZKaYnDwTLHLzig2GuUjjE2NaCJPy4jg== +"@swc/core-darwin-arm64@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz#bf66e3f4f00e6fe9d95e8a33f780e6c40fca946d" + integrity sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ== -"@swc/core-darwin-x64@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.6.1.tgz#8b7070fcee4a4570d0af245c4614ca4e492dfd5b" - integrity sha512-/tXwQibkDNLVbAtr7PUQI0iQjoB708fjhDDDfJ6WILSBVZ3+qs/LHjJ7jHwumEYxVq1XA7Fv2Q7SE/ZSQoWHcQ== +"@swc/core-darwin-x64@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz#0a77d2d79ef2c789f9d40a86784bbf52c5f9877f" + integrity sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw== -"@swc/core-linux-arm-gnueabihf@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.6.1.tgz#bea6d2e75127bbc65a664284f012ffa90c8325d5" - integrity sha512-aDgipxhJTms8iH78emHVutFR2c16LNhO+NTRCdYi+X4PyIn58/DyYTH6VDZ0AeEcS5f132ZFldU5AEgExwihXA== +"@swc/core-linux-arm-gnueabihf@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz#80fa3a6a36034ffdbbba73e26c8f27cb13111a33" + integrity sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g== -"@swc/core-linux-arm64-gnu@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.6.1.tgz#5c84d804ec23cf54b31c0bc0b4bdd30ec5d43ce8" - integrity sha512-XkJ+eO4zUKG5g458RyhmKPyBGxI0FwfWFgpfIj5eDybxYJ6s4HBT5MoxyBLorB5kMlZ0XoY/usUMobPVY3nL0g== +"@swc/core-linux-arm64-gnu@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz#42da87f445bc3e26da01d494246884006d9b9a1a" + integrity sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw== -"@swc/core-linux-arm64-musl@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.6.1.tgz#e167a350bec12caebc97304068c3ffbad6c398ce" - integrity sha512-dr6YbLBg/SsNxs1hDqJhxdcrS8dGMlOXJwXIrUvACiA8jAd6S5BxYCaqsCefLYXtaOmu0bbx1FB/evfodqB70Q== +"@swc/core-linux-arm64-musl@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz#c9cec610525dc9e9b11ef26319db3780812dfa54" + integrity sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw== -"@swc/core-linux-x64-gnu@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.6.1.tgz#fdd4e1d63b3e53d195e2ddcb9cb5ad9f31995796" - integrity sha512-A0b/3V+yFy4LXh3O9umIE7LXPC7NBWdjl6AQYqymSMcMu0EOb1/iygA6s6uWhz9y3e172Hpb9b/CGsuD8Px/bg== +"@swc/core-linux-x64-gnu@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz#1cda2df38a4ab8905ba6ac3aa16e4ad710b6f2de" + integrity sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA== -"@swc/core-linux-x64-musl@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.6.1.tgz#81a312dd9e62da5f4c48e3cd23b6c6d28a31ac42" - integrity sha512-5dJjlzZXhC87nZZZWbpiDP8kBIO0ibis893F/rtPIQBI5poH+iJuA32EU3wN4/WFHeK4et8z6SGSVghPtWyk4g== +"@swc/core-linux-x64-musl@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz#5d634efff33f47c8d6addd84291ab606903d1cfd" + integrity sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ== -"@swc/core-win32-arm64-msvc@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.6.1.tgz#e131f579a69c5d807013e54ccb311e10caa27bcb" - integrity sha512-HBi1ZlwvfcUibLtT3g/lP57FaDPC799AD6InolB2KSgkqyBbZJ9wAXM8/CcH67GLIP0tZ7FqblrJTzGXxetTJQ== +"@swc/core-win32-arm64-msvc@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz#bc54f2e3f8f180113b7a092b1ee1eaaab24df62b" + integrity sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw== -"@swc/core-win32-ia32-msvc@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.6.1.tgz#9f3d88cf0e826aa8222a695177a065ed2899eb21" - integrity sha512-AKqHohlWERclexar5y6ux4sQ8yaMejEXNxeKXm7xPhXrp13/1p4/I3E5bPVX/jMnvpm4HpcKSP0ee2WsqmhhPw== +"@swc/core-win32-ia32-msvc@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz#f1df344c06283643d1fe66c6931b350347b73722" + integrity sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg== -"@swc/core-win32-x64-msvc@1.6.1": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.6.1.tgz#b2082710bc46c484a2c9f2e33a15973806e5031d" - integrity sha512-0dLdTLd+ONve8kgC5T6VQ2Y5G+OZ7y0ujjapnK66wpvCBM6BKYGdT/OKhZKZydrC5gUKaxFN6Y5oOt9JOFUrOQ== +"@swc/core-win32-x64-msvc@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz#a6f9dc1df66c8db96d70091abedd78cc52544724" + integrity sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g== -"@swc/core@^1.3.74": - version "1.6.1" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.6.1.tgz#a899a205cfaa8e23f805451ef4787987e03b8920" - integrity sha512-Yz5uj5hNZpS5brLtBvKY0L4s2tBAbQ4TjmW8xF1EC3YLFxQRrUjMP49Zm1kp/KYyYvTkSaG48Ffj2YWLu9nChw== +"@swc/core@^1.7.39": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.11.29.tgz#bce20113c47fcd6251d06262b8b8c063f8e86a20" + integrity sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA== dependencies: "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.8" + "@swc/types" "^0.1.21" optionalDependencies: - "@swc/core-darwin-arm64" "1.6.1" - "@swc/core-darwin-x64" "1.6.1" - "@swc/core-linux-arm-gnueabihf" "1.6.1" - "@swc/core-linux-arm64-gnu" "1.6.1" - "@swc/core-linux-arm64-musl" "1.6.1" - "@swc/core-linux-x64-gnu" "1.6.1" - "@swc/core-linux-x64-musl" "1.6.1" - "@swc/core-win32-arm64-msvc" "1.6.1" - "@swc/core-win32-ia32-msvc" "1.6.1" - "@swc/core-win32-x64-msvc" "1.6.1" + "@swc/core-darwin-arm64" "1.11.29" + "@swc/core-darwin-x64" "1.11.29" + "@swc/core-linux-arm-gnueabihf" "1.11.29" + "@swc/core-linux-arm64-gnu" "1.11.29" + "@swc/core-linux-arm64-musl" "1.11.29" + "@swc/core-linux-x64-gnu" "1.11.29" + "@swc/core-linux-x64-musl" "1.11.29" + "@swc/core-win32-arm64-msvc" "1.11.29" + "@swc/core-win32-ia32-msvc" "1.11.29" + "@swc/core-win32-x64-msvc" "1.11.29" "@swc/counter@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/types@^0.1.8": - version "0.1.8" - resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.8.tgz#2c81d107c86cfbd0c3a05ecf7bb54c50dfa58a95" - integrity sha512-RNFA3+7OJFNYY78x0FYwi1Ow+iF1eF5WvmfY1nXPOEH4R2p/D4Cr1vzje7dNAI2aLFqpv8Wyz4oKSWqIZArpQA== +"@swc/html-darwin-arm64@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.11.29.tgz#7bd6d10115ffe155ecd757387b5aff318b02b5a0" + integrity sha512-q53kn/HI0n/+pecsOB2gxqITbRAhtBG7VI520SIWuCGXHPsTQ/1VOrhLMNvyfw1xVhRyFal7BpAvfGUORCl0sw== + +"@swc/html-darwin-x64@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.11.29.tgz#ccafed56081932ffaa51be1788cef88eb9a144d1" + integrity sha512-YfQPjh5WoDqOxsA7vDOOSnxEPc1Ki4SuZ0ufR4t8jYdMOFsU3AhZQ/sgBZLpTzegBTutUn7/7yy8VSoFngeR7Q== + +"@swc/html-linux-arm-gnueabihf@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.11.29.tgz#e784a1a0f69034e9dd52a9019ff80f0d5eb91433" + integrity sha512-dC3aEv1mqAUkY9TiZWOE2IcYpvxJzw0LdvkDzGW5072JSlZZYQMqq2Llwg63LIp6qBlj1JLHMLnBqk7Ubatmjw== + +"@swc/html-linux-arm64-gnu@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.11.29.tgz#ef015d81d3a011d6c273a428eee130b3aac790b7" + integrity sha512-seo+lCiBUggTR9NsHE4qVC+7+XIfLHK7yxWiIsXb8nNAXDcqVZ0Rxv8O1Y1GTeJfUlcCt1koahCG2AeyWpYFBg== + +"@swc/html-linux-arm64-musl@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.11.29.tgz#b20e4b442287367c4c1d62db2b8065106542f432" + integrity sha512-bK8K6t3hHgaZZ1vMNaZ+8x42EWJPEX1Dx4zi6ulMhKa1uan+DjW5SiMlUg0an16fFSYfE+r9oFC4cFEbGP1o4Q== + +"@swc/html-linux-x64-gnu@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.11.29.tgz#c45505b3e22c02dc8bdef8b3da48ba855527a62c" + integrity sha512-34tSms5TkRUCr+J6uuSE/11ECcfIpp5R1ODuIgxZRUd/u88pQGKzLVNLWGPLw4b3cZSjnAn+PFJl7BtaYl0UyQ== + +"@swc/html-linux-x64-musl@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.11.29.tgz#86116e7db3cf02b1a627bc94c374d26ce6f9d68a" + integrity sha512-oJLLrX94ccaniWdQt8PH6K2u8aN/ehBo/YPg84LycFtaud/k73Fa1kh6Neq8vbWI4CugIWTl4LXWoHm+l+QYeA== + +"@swc/html-win32-arm64-msvc@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.11.29.tgz#cf7e57b8c0b52f7f93abc307b0cb78d8213b3c13" + integrity sha512-nw4TCFfA4YV6jicRdicJZPKW+ihOZPMKEG/4bj1/6HqXw1T2pXI070ASOLE0KOHYuoyV/jConEHfIjlU0olneA== + +"@swc/html-win32-ia32-msvc@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.11.29.tgz#61c91409c3fcdf942891c02aa2a2eac1892d1907" + integrity sha512-rO6X4qOofGpKV8pyZ7VblJn+J3PHEqeWHJkJfzwP7c04Flr1oLyuLbTU8lwf8enXrTAZqitHZs+OpofKcUwHEw== + +"@swc/html-win32-x64-msvc@1.11.29": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.11.29.tgz#0e78b507d2bf28315487655852008cdecfe84535" + integrity sha512-GSCihzBItEPJAeLzkAtw0ZGbxRGMsGt1Z1ugo0uHva1R3Eybkqu9qoax1tGAON+EJzeiHRqphhNgh8MVDpnKnQ== + +"@swc/html@^1.7.39": + version "1.11.29" + resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.11.29.tgz#6e9e1b8ea65baa0d6f25cb883565a5e7d22d2858" + integrity sha512-Tsk/o6Eo3lDvHPGjLqVwXGEdC1bemGzByPWx/TrF5N7qEsanRblPeRcJzLl6LbWa80pRYIRB6T4VqdXXZqklaw== + dependencies: + "@swc/counter" "^0.1.3" + optionalDependencies: + "@swc/html-darwin-arm64" "1.11.29" + "@swc/html-darwin-x64" "1.11.29" + "@swc/html-linux-arm-gnueabihf" "1.11.29" + "@swc/html-linux-arm64-gnu" "1.11.29" + "@swc/html-linux-arm64-musl" "1.11.29" + "@swc/html-linux-x64-gnu" "1.11.29" + "@swc/html-linux-x64-musl" "1.11.29" + "@swc/html-win32-arm64-msvc" "1.11.29" + "@swc/html-win32-ia32-msvc" "1.11.29" + "@swc/html-win32-x64-msvc" "1.11.29" + +"@swc/types@^0.1.21": + version "0.1.21" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.21.tgz#6fcadbeca1d8bc89e1ab3de4948cef12344a38c0" + integrity sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ== dependencies: "@swc/counter" "^0.1.3" @@ -2314,23 +3901,216 @@ dependencies: "@types/node" "*" -"@types/d3-scale-chromatic@^3.0.0": - version "3.0.3" - resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz#fc0db9c10e789c351f4c42d96f31f2e4df8f5644" - integrity sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw== +"@types/d3-array@*": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5" + integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg== -"@types/d3-scale@^4.0.3": - version "4.0.8" - resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" - integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ== +"@types/d3-axis@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.6.tgz#e760e5765b8188b1defa32bc8bb6062f81e4c795" + integrity sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.6.tgz#c2f4362b045d472e1b186cdbec329ba52bdaee6c" + integrity sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.6.tgz#1706ca40cf7ea59a0add8f4456efff8f8775793d" + integrity sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-contour@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.6.tgz#9ada3fa9c4d00e3a5093fed0356c7ab929604231" + integrity sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz#185c1a80cc807fdda2a3fe960f7c11c4a27952e1" + integrity sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw== + +"@types/d3-dispatch@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.6.tgz#096efdf55eb97480e3f5621ff9a8da552f0961e7" + integrity sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ== + +"@types/d3-drag@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz#0a351f996dc99b37f4fa58b492c2d1c04e3dac17" + integrity sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g== + +"@types/d3-ease@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-fetch@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz#c04a2b4f23181aa376f30af0283dbc7b3b569980" + integrity sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.10.tgz#6dc8fc6e1f35704f3b057090beeeb7ac674bff1a" + integrity sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw== + +"@types/d3-format@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.4.tgz#b1e4465644ddb3fdf3a263febb240a6cd616de90" + integrity sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g== + +"@types/d3-geo@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.1.0.tgz#b9e56a079449174f0a2c8684a9a4df3f60522440" + integrity sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz#6023fb3b2d463229f2d680f9ac4b47466f71f17b" + integrity sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg== + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-polygon@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz#dfae54a6d35d19e76ac9565bcb32a8e54693189c" + integrity sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA== + +"@types/d3-quadtree@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz#d4740b0fe35b1c58b66e1488f4e7ed02952f570f" + integrity sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg== + +"@types/d3-random@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.3.tgz#ed995c71ecb15e0cd31e22d9d5d23942e3300cfb" + integrity sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ== + +"@types/d3-scale-chromatic@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" + integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== + +"@types/d3-scale@*": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== dependencies: "@types/d3-time" "*" +"@types/d3-selection@*": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" + integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== + +"@types/d3-shape@*": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.7.tgz#2b7b423dc2dfe69c8c93596e673e37443348c555" + integrity sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz#d6bc1e6b6a7db69cccfbbdd4c34b70632d9e9db2" + integrity sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg== + "@types/d3-time@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be" integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw== +"@types/d3-timer@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + +"@types/d3-transition@*": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706" + integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.3.tgz#d4550a85d08f4978faf0a4c36b848c61eaac07e2" + integrity sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -2338,7 +4118,7 @@ dependencies: "@types/ms" "*" -"@types/eslint-scope@^3.7.3": +"@types/eslint-scope@^3.7.3", "@types/eslint-scope@^3.7.7": version "3.7.7" resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== @@ -2366,6 +4146,11 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/estree@^1.0.6": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": version "4.19.3" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.3.tgz#e469a13e4186c9e1c0418fb17be8bc8ff1b19a7a" @@ -2386,6 +4171,11 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/geojson@*": + version "7946.0.16" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" + integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== + "@types/gtag.js@^0.0.12": version "0.0.12" resolved "https://registry.yarnpkg.com/@types/gtag.js/-/gtag.js-0.0.12.tgz#095122edca896689bdfcdd73b057e23064d23572" @@ -2459,7 +4249,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -2512,11 +4302,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== -"@types/parse-json@^4.0.0": - version "4.0.2" - resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" - integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== - "@types/parse5@^6.0.0": version "6.0.3" resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb" @@ -2629,6 +4414,11 @@ dependencies: "@types/node" "*" +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/unist/-/unist-3.0.2.tgz#6dd61e43ef60b34086287f83683a5c1b2dc53d20" @@ -2678,21 +4468,44 @@ "@webassemblyjs/helper-numbers" "1.11.6" "@webassemblyjs/helper-wasm-bytecode" "1.11.6" +"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" + integrity sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== + dependencies: + "@webassemblyjs/helper-numbers" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/floating-point-hex-parser@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== +"@webassemblyjs/floating-point-hex-parser@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz#fcca1eeddb1cc4e7b6eed4fc7956d6813b21b9fb" + integrity sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== + "@webassemblyjs/helper-api-error@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== +"@webassemblyjs/helper-api-error@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz#e0a16152248bc38daee76dd7e21f15c5ef3ab1e7" + integrity sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== + "@webassemblyjs/helper-buffer@1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== +"@webassemblyjs/helper-buffer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz#822a9bc603166531f7d5df84e67b5bf99b72b96b" + integrity sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== + "@webassemblyjs/helper-numbers@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" @@ -2702,11 +4515,25 @@ "@webassemblyjs/helper-api-error" "1.11.6" "@xtuc/long" "4.2.2" +"@webassemblyjs/helper-numbers@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz#dbd932548e7119f4b8a7877fd5a8d20e63490b2d" + integrity sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.13.2" + "@webassemblyjs/helper-api-error" "1.13.2" + "@xtuc/long" "4.2.2" + "@webassemblyjs/helper-wasm-bytecode@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== +"@webassemblyjs/helper-wasm-bytecode@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz#e556108758f448aae84c850e593ce18a0eb31e0b" + integrity sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== + "@webassemblyjs/helper-wasm-section@1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" @@ -2717,6 +4544,16 @@ "@webassemblyjs/helper-wasm-bytecode" "1.11.6" "@webassemblyjs/wasm-gen" "1.12.1" +"@webassemblyjs/helper-wasm-section@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz#9629dda9c4430eab54b591053d6dc6f3ba050348" + integrity sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/ieee754@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" @@ -2724,6 +4561,13 @@ dependencies: "@xtuc/ieee754" "^1.2.0" +"@webassemblyjs/ieee754@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz#1c5eaace1d606ada2c7fd7045ea9356c59ee0dba" + integrity sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== + dependencies: + "@xtuc/ieee754" "^1.2.0" + "@webassemblyjs/leb128@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" @@ -2731,11 +4575,23 @@ dependencies: "@xtuc/long" "4.2.2" +"@webassemblyjs/leb128@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.13.2.tgz#57c5c3deb0105d02ce25fa3fd74f4ebc9fd0bbb0" + integrity sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== + dependencies: + "@xtuc/long" "4.2.2" + "@webassemblyjs/utf8@1.11.6": version "1.11.6" resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== +"@webassemblyjs/utf8@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.13.2.tgz#917a20e93f71ad5602966c2d685ae0c6c21f60f1" + integrity sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== + "@webassemblyjs/wasm-edit@^1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" @@ -2750,6 +4606,20 @@ "@webassemblyjs/wasm-parser" "1.12.1" "@webassemblyjs/wast-printer" "1.12.1" +"@webassemblyjs/wasm-edit@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz#ac6689f502219b59198ddec42dcd496b1004d597" + integrity sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/helper-wasm-section" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-opt" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wast-printer" "1.14.1" + "@webassemblyjs/wasm-gen@1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" @@ -2761,6 +4631,17 @@ "@webassemblyjs/leb128" "1.11.6" "@webassemblyjs/utf8" "1.11.6" +"@webassemblyjs/wasm-gen@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz#991e7f0c090cb0bb62bbac882076e3d219da9570" + integrity sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + "@webassemblyjs/wasm-opt@1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" @@ -2771,6 +4652,16 @@ "@webassemblyjs/wasm-gen" "1.12.1" "@webassemblyjs/wasm-parser" "1.12.1" +"@webassemblyjs/wasm-opt@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz#e6f71ed7ccae46781c206017d3c14c50efa8106b" + integrity sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" @@ -2783,6 +4674,18 @@ "@webassemblyjs/leb128" "1.11.6" "@webassemblyjs/utf8" "1.11.6" +"@webassemblyjs/wasm-parser@1.14.1", "@webassemblyjs/wasm-parser@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz#b3e13f1893605ca78b52c68e54cf6a865f90b9fb" + integrity sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-api-error" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + "@webassemblyjs/wast-printer@1.12.1": version "1.12.1" resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" @@ -2791,6 +4694,14 @@ "@webassemblyjs/ast" "1.12.1" "@xtuc/long" "4.2.2" +"@webassemblyjs/wast-printer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz#3bb3e9638a8ae5fdaf9610e7a06b4d9f9aa6fe07" + integrity sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@xtuc/long" "4.2.2" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -2801,13 +4712,6 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== -abort-controller@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" - integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== - dependencies: - event-target-shim "^5.0.0" - accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -2838,7 +4742,12 @@ acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.7.1, acorn@^8.8.2: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c" integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== -address@^1.0.1, address@^1.1.2: +acorn@^8.14.0: + version "8.14.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" + integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + +address@^1.0.1: version "1.2.2" resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e" integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA== @@ -2870,7 +4779,7 @@ ajv-formats@2.1.1, ajv-formats@^2.1.1: dependencies: ajv "^8.0.0" -ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: +ajv-keywords@^3.5.2: version "3.5.2" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== @@ -2892,7 +4801,7 @@ ajv@8.11.0: require-from-string "^2.0.2" uri-js "^4.2.2" -ajv@^6.12.2, ajv@^6.12.5: +ajv@^6.12.5: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -2912,33 +4821,38 @@ ajv@^8.0.0, ajv@^8.9.0: require-from-string "^2.0.2" uri-js "^4.4.1" -algoliasearch-helper@^3.13.3: - version "3.21.0" - resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.21.0.tgz#d28fdb61199b5c229714788bfb812376b18aaf28" - integrity sha512-hjVOrL15I3Y3K8xG0icwG1/tWE+MocqBrhW6uVBWpU+/kVEMK0BnM2xdssj6mZM61eJ4iRxHR0djEI3ENOpR8w== +algoliasearch-helper@^3.22.6: + version "3.25.0" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz#15cc79ad7909db66b8bb5a5a9c38b40e3941fa2f" + integrity sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw== dependencies: "@algolia/events" "^4.0.1" -algoliasearch@^4.18.0, algoliasearch@^4.19.1: - version "4.23.3" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.23.3.tgz#e09011d0a3b0651444916a3e6bbcba064ec44b60" - integrity sha512-Le/3YgNvjW9zxIQMRhUHuhiUjAlKY/zsdZpfq4dlLqg6mEm0nL6yk+7f2hDOtLpxsgE4jSzDmvHL7nXdBp5feg== +algoliasearch@^5.14.2, algoliasearch@^5.17.1: + version "5.25.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.25.0.tgz#7337b097deadeca0e6e985c0f8724abea189994f" + integrity sha512-n73BVorL4HIwKlfJKb4SEzAYkR3Buwfwbh+MYxg2mloFph2fFGV58E90QTzdbfzWrLn4HE5Czx/WTjI8fcHaMg== dependencies: - "@algolia/cache-browser-local-storage" "4.23.3" - "@algolia/cache-common" "4.23.3" - "@algolia/cache-in-memory" "4.23.3" - "@algolia/client-account" "4.23.3" - "@algolia/client-analytics" "4.23.3" - "@algolia/client-common" "4.23.3" - "@algolia/client-personalization" "4.23.3" - "@algolia/client-search" "4.23.3" - "@algolia/logger-common" "4.23.3" - "@algolia/logger-console" "4.23.3" - "@algolia/recommend" "4.23.3" - "@algolia/requester-browser-xhr" "4.23.3" - "@algolia/requester-common" "4.23.3" - "@algolia/requester-node-http" "4.23.3" - "@algolia/transporter" "4.23.3" + "@algolia/client-abtesting" "5.25.0" + "@algolia/client-analytics" "5.25.0" + "@algolia/client-common" "5.25.0" + "@algolia/client-insights" "5.25.0" + "@algolia/client-personalization" "5.25.0" + "@algolia/client-query-suggestions" "5.25.0" + "@algolia/client-search" "5.25.0" + "@algolia/ingestion" "1.25.0" + "@algolia/monitoring" "1.25.0" + "@algolia/recommend" "5.25.0" + "@algolia/requester-browser-xhr" "5.25.0" + "@algolia/requester-fetch" "5.25.0" + "@algolia/requester-node-http" "5.25.0" + +allof-merge@^0.6.6: + version "0.6.6" + resolved "https://registry.yarnpkg.com/allof-merge/-/allof-merge-0.6.6.tgz#1c675c7170e1b24bd3dc96db9c3459c0e7cfbea2" + integrity sha512-116eZBf2he0/J4Tl7EYMz96I5Anaeio+VL0j/H2yxW9CoYQAMMv8gYcwkVRoO7XfIOv/qzSTfVzDVGAYxKFi3g== + dependencies: + json-crawl "^0.5.3" ansi-align@^3.0.1: version "3.0.1" @@ -2947,6 +4861,13 @@ ansi-align@^3.0.1: dependencies: string-width "^4.1.0" +ansi-escapes@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + ansi-html-community@^0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" @@ -3021,26 +4942,6 @@ array-union@^2.1.0: resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== -asn1.js@^4.10.1: - version "4.10.1" - resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-4.10.1.tgz#b9c2bf5805f1e64aadeed6df3a2bfafb5a73f5a0" - integrity sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw== - dependencies: - bn.js "^4.0.0" - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - -assert@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" - integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== - dependencies: - call-bind "^1.0.2" - is-nan "^1.3.2" - object-is "^1.1.5" - object.assign "^4.1.4" - util "^0.12.5" - astring@^1.8.0: version "1.8.6" resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731" @@ -3061,7 +4962,7 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== -autoprefixer@^10.4.13, autoprefixer@^10.4.14, autoprefixer@^10.4.19: +autoprefixer@^10.4.13, autoprefixer@^10.4.19: version "10.4.19" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.19.tgz#ad25a856e82ee9d7898c59583c1afeb3fa65f89f" integrity sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew== @@ -3073,24 +4974,22 @@ autoprefixer@^10.4.13, autoprefixer@^10.4.14, autoprefixer@^10.4.19: picocolors "^1.0.0" postcss-value-parser "^4.2.0" -available-typed-arrays@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" - integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== +autoprefixer@^10.4.21: + version "10.4.21" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.21.tgz#77189468e7a8ad1d9a37fbc08efc9f480cf0a95d" + integrity sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ== dependencies: - possible-typed-array-names "^1.0.0" + browserslist "^4.24.4" + caniuse-lite "^1.0.30001702" + fraction.js "^4.3.7" + normalize-range "^0.1.2" + picocolors "^1.1.1" + postcss-value-parser "^4.2.0" -axios@^0.25.0: - version "0.25.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.25.0.tgz#349cfbb31331a9b4453190791760a8d35b093e0a" - integrity sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g== - dependencies: - follow-redirects "^1.14.7" - -babel-loader@^9.1.3: - version "9.1.3" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.3.tgz#3d0e01b4e69760cc694ee306fe16d358aa1c6f9a" - integrity sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw== +babel-loader@^9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.2.1.tgz#04c7835db16c246dd19ba0914418f3937797587b" + integrity sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA== dependencies: find-cache-dir "^4.0.0" schema-utils "^4.0.0" @@ -3111,7 +5010,7 @@ babel-plugin-polyfill-corejs2@^0.4.10: "@babel/helper-define-polyfill-provider" "^0.6.2" semver "^6.3.1" -babel-plugin-polyfill-corejs3@^0.10.1, babel-plugin-polyfill-corejs3@^0.10.4: +babel-plugin-polyfill-corejs3@^0.10.4: version "0.10.4" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz#789ac82405ad664c20476d0233b485281deb9c77" integrity sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg== @@ -3119,6 +5018,14 @@ babel-plugin-polyfill-corejs3@^0.10.1, babel-plugin-polyfill-corejs3@^0.10.4: "@babel/helper-define-polyfill-provider" "^0.6.1" core-js-compat "^3.36.1" +babel-plugin-polyfill-corejs3@^0.11.0: + version "0.11.1" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz#4e4e182f1bb37c7ba62e2af81d8dd09df31344f6" + integrity sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ== + dependencies: + "@babel/helper-define-polyfill-provider" "^0.6.3" + core-js-compat "^3.40.0" + babel-plugin-polyfill-regenerator@^0.6.1: version "0.6.2" resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz#addc47e240edd1da1058ebda03021f382bba785e" @@ -3165,16 +5072,6 @@ bl@^4.0.3: inherits "^2.0.4" readable-stream "^3.4.0" -bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9: - version "4.12.0" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" - integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== - -bn.js@^5.0.0, bn.js@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.1.tgz#0bc527a6a0d18d0aa8d5b0538ce4a77dccfa7b70" - integrity sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ== - body-parser@1.20.2: version "1.20.2" resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" @@ -3256,74 +5153,7 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -brorand@^1.0.1, brorand@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f" - integrity sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w== - -browserify-aes@^1.0.4, browserify-aes@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/browserify-aes/-/browserify-aes-1.2.0.tgz#326734642f403dabc3003209853bb70ad428ef48" - integrity sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA== - dependencies: - buffer-xor "^1.0.3" - cipher-base "^1.0.0" - create-hash "^1.1.0" - evp_bytestokey "^1.0.3" - inherits "^2.0.1" - safe-buffer "^5.0.1" - -browserify-cipher@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/browserify-cipher/-/browserify-cipher-1.0.1.tgz#8d6474c1b870bfdabcd3bcfcc1934a10e94f15f0" - integrity sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w== - dependencies: - browserify-aes "^1.0.4" - browserify-des "^1.0.0" - evp_bytestokey "^1.0.0" - -browserify-des@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/browserify-des/-/browserify-des-1.0.2.tgz#3af4f1f59839403572f1c66204375f7a7f703e9c" - integrity sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A== - dependencies: - cipher-base "^1.0.1" - des.js "^1.0.0" - inherits "^2.0.1" - safe-buffer "^5.1.2" - -browserify-rsa@^4.0.0, browserify-rsa@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/browserify-rsa/-/browserify-rsa-4.1.0.tgz#b2fd06b5b75ae297f7ce2dc651f918f5be158c8d" - integrity sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog== - dependencies: - bn.js "^5.0.0" - randombytes "^2.0.1" - -browserify-sign@^4.0.0: - version "4.2.3" - resolved "https://registry.yarnpkg.com/browserify-sign/-/browserify-sign-4.2.3.tgz#7afe4c01ec7ee59a89a558a4b75bd85ae62d4208" - integrity sha512-JWCZW6SKhfhjJxO8Tyiiy+XYB7cqd2S5/+WeYHsKdNKFlCBhKbblba1A/HN/90YwtxKc8tCErjffZl++UNmGiw== - dependencies: - bn.js "^5.2.1" - browserify-rsa "^4.1.0" - create-hash "^1.2.0" - create-hmac "^1.1.7" - elliptic "^6.5.5" - hash-base "~3.0" - inherits "^2.0.4" - parse-asn1 "^5.1.7" - readable-stream "^2.3.8" - safe-buffer "^5.2.1" - -browserify-zlib@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f" - integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA== - dependencies: - pako "~1.0.5" - -browserslist@^4.0.0, browserslist@^4.18.1, browserslist@^4.21.10, browserslist@^4.22.2, browserslist@^4.23.0: +browserslist@^4.0.0, browserslist@^4.21.10, browserslist@^4.22.2, browserslist@^4.23.0: version "4.23.1" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.1.tgz#ce4af0534b3d37db5c1a4ca98b9080f985041e96" integrity sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw== @@ -3333,6 +5163,16 @@ browserslist@^4.0.0, browserslist@^4.18.1, browserslist@^4.21.10, browserslist@^ node-releases "^2.0.14" update-browserslist-db "^1.0.16" +browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4, browserslist@^4.24.5: + version "4.24.5" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.5.tgz#aa0f5b8560fe81fde84c6dcb38f759bafba0e11b" + integrity sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw== + dependencies: + caniuse-lite "^1.0.30001716" + electron-to-chromium "^1.5.149" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -3343,11 +5183,6 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer-xor@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-1.0.3.tgz#26e61ed1422fb70dd42e6e36729ed51d855fe8d9" - integrity sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ== - buffer@^5.2.1, buffer@^5.5.0: version "5.7.1" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" @@ -3364,11 +5199,6 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" -builtin-status-codes@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8" - integrity sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ== - bytes@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" @@ -3397,7 +5227,15 @@ cacheable-request@^10.2.8: normalize-url "^8.0.0" responselike "^3.0.0" -call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.7: +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + +call-bind@^1.0.5, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== @@ -3408,6 +5246,14 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bound@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + call-me-maybe@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.2.tgz#03f964f19522ba643b1b0693acb9152fe2074baa" @@ -3456,6 +5302,11 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001629: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz" integrity sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA== +caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001716, caniuse-lite@^1.0.30001718: + version "1.0.30001718" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz#dae13a9c80d517c30c6197515a96131c194d8f82" + integrity sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw== + ccount@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" @@ -3470,7 +5321,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: +chalk@^4.0.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -3525,7 +5376,7 @@ cheerio-select@^2.1.0: domhandler "^5.0.3" domutils "^3.0.1" -cheerio@^1.0.0-rc.12: +cheerio@1.0.0-rc.12: version "1.0.0-rc.12" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== @@ -3538,7 +5389,26 @@ cheerio@^1.0.0-rc.12: parse5 "^7.0.0" parse5-htmlparser2-tree-adapter "^7.0.0" -"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.2, chokidar@^3.5.3: +chevrotain-allstar@~0.3.0: + version "0.3.1" + resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz#b7412755f5d83cc139ab65810cdb00d8db40e6ca" + integrity sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw== + dependencies: + lodash-es "^4.17.21" + +chevrotain@~11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-11.0.3.tgz#88ffc1fb4b5739c715807eaeedbbf200e202fc1b" + integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== + dependencies: + "@chevrotain/cst-dts-gen" "11.0.3" + "@chevrotain/gast" "11.0.3" + "@chevrotain/regexp-to-ast" "11.0.3" + "@chevrotain/types" "11.0.3" + "@chevrotain/utils" "11.0.3" + lodash-es "4.17.21" + +chokidar@^3.5.3: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -3553,6 +5423,13 @@ cheerio@^1.0.0-rc.12: optionalDependencies: fsevents "~2.3.2" +chokidar@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -3568,14 +5445,6 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" - integrity sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - clean-css@^5.2.2, clean-css@^5.3.2, clean-css@~5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" @@ -3768,6 +5637,16 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== + +confbox@^0.2.1: + version "0.2.2" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.2.tgz#8652f53961c74d9e081784beed78555974a9c110" + integrity sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ== + config-chain@^1.1.11: version "1.1.13" resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" @@ -3792,20 +5671,10 @@ connect-history-api-fallback@^2.0.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8" integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA== -consola@^2.15.3: - version "2.15.3" - resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" - integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== - -console-browserify@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" - integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== - -constants-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" - integrity sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ== +consola@^3.2.3: + version "3.4.2" + resolved "https://registry.yarnpkg.com/consola/-/consola-3.4.2.tgz#5af110145397bb67afdab77013fdc34cae590ea7" + integrity sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA== content-disposition@0.5.2: version "0.5.2" @@ -3870,6 +5739,13 @@ core-js-compat@^3.31.0, core-js-compat@^3.36.1: dependencies: browserslist "^4.23.0" +core-js-compat@^3.40.0: + version "3.42.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.42.0.tgz#ce19c29706ee5806e26d3cb3c542d4cfc0ed51bb" + integrity sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ== + dependencies: + browserslist "^4.24.4" + core-js-pure@^3.30.2: version "3.37.1" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.37.1.tgz#2b4b34281f54db06c9a9a5bd60105046900553bd" @@ -3892,16 +5768,12 @@ cose-base@^1.0.0: dependencies: layout-base "^1.0.0" -cosmiconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" - integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== +cose-base@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-2.2.0.tgz#1c395c35b6e10bb83f9769ca8b817d614add5c01" + integrity sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g== dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.7.2" + layout-base "^2.0.0" cosmiconfig@^8.1.3, cosmiconfig@^8.3.5: version "8.3.6" @@ -3913,37 +5785,6 @@ cosmiconfig@^8.1.3, cosmiconfig@^8.3.5: parse-json "^5.2.0" path-type "^4.0.0" -create-ecdh@^4.0.0: - version "4.0.4" - resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" - integrity sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A== - dependencies: - bn.js "^4.1.0" - elliptic "^6.5.3" - -create-hash@^1.1.0, create-hash@^1.1.2, create-hash@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" - integrity sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg== - dependencies: - cipher-base "^1.0.1" - inherits "^2.0.1" - md5.js "^1.3.4" - ripemd160 "^2.0.1" - sha.js "^2.4.0" - -create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: - version "1.1.7" - resolved "https://registry.yarnpkg.com/create-hmac/-/create-hmac-1.1.7.tgz#69170c78b3ab957147b2b8b04572e47ead2243ff" - integrity sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg== - dependencies: - cipher-base "^1.0.3" - create-hash "^1.1.0" - inherits "^2.0.1" - ripemd160 "^2.0.0" - safe-buffer "^5.0.1" - sha.js "^2.4.8" - cross-fetch@3.1.5: version "3.1.5" resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" @@ -3960,23 +5801,6 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.3: shebang-command "^2.0.0" which "^2.0.1" -crypto-browserify@^3.12.0: - version "3.12.0" - resolved "https://registry.yarnpkg.com/crypto-browserify/-/crypto-browserify-3.12.0.tgz#396cf9f3137f03e4b8e532c58f698254e00f80ec" - integrity sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg== - dependencies: - browserify-cipher "^1.0.0" - browserify-sign "^4.0.0" - create-ecdh "^4.0.0" - create-hash "^1.1.0" - create-hmac "^1.1.0" - diffie-hellman "^5.0.0" - inherits "^2.0.1" - pbkdf2 "^3.0.3" - public-encrypt "^4.0.0" - randombytes "^2.0.0" - randomfill "^1.0.3" - crypto-js@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631" @@ -3989,11 +5813,27 @@ crypto-random-string@^4.0.0: dependencies: type-fest "^1.0.1" +css-blank-pseudo@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-7.0.1.tgz#32020bff20a209a53ad71b8675852b49e8d57e46" + integrity sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag== + dependencies: + postcss-selector-parser "^7.0.0" + css-declaration-sorter@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-7.2.0.tgz#6dec1c9523bc4a643e088aab8f09e67a54961024" integrity sha512-h70rUM+3PNFuaBDTLe8wF/cdWu+dOZmb7pJt8Z2sedYbAcQVQV/tEchueg3GWxwqS0cxtbxmaHEdkNACqcvsow== +css-has-pseudo@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/css-has-pseudo/-/css-has-pseudo-7.0.2.tgz#fb42e8de7371f2896961e1f6308f13c2c7019b72" + integrity sha512-nzol/h+E0bId46Kn2dQH5VElaknX2Sr0hFuB/1EomdC7j+OISt2ZzK7EHX9DZDY53WbIVAR7FYKSO2XnSf07MQ== + dependencies: + "@csstools/selector-specificity" "^5.0.0" + postcss-selector-parser "^7.0.0" + postcss-value-parser "^4.2.0" + css-loader@^6.8.1: version "6.11.0" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.11.0.tgz#33bae3bf6363d0a7c2cf9031c96c744ff54d85ba" @@ -4020,6 +5860,11 @@ css-minimizer-webpack-plugin@^5.0.1: schema-utils "^4.0.1" serialize-javascript "^6.0.1" +css-prefers-color-scheme@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/css-prefers-color-scheme/-/css-prefers-color-scheme-10.0.0.tgz#ba001b99b8105b8896ca26fc38309ddb2278bd3c" + integrity sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ== + css-select@^4.1.3: version "4.3.0" resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" @@ -4063,6 +5908,11 @@ css-what@^6.0.1, css-what@^6.1.0: resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +cssdb@^8.3.0: + version "8.3.0" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.3.0.tgz#940becad497b8509ad822a28fb0cfe54c969ccfe" + integrity sha512-c7bmItIg38DgGjSwDPZOYF/2o0QU/sSgkWOMyl8votOfgFuyiFKWPesmCGEsrGLxEA9uL540cp8LdaGEjUGsZQ== + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -4149,10 +5999,17 @@ cytoscape-cose-bilkent@^4.1.0: dependencies: cose-base "^1.0.0" -cytoscape@^3.28.1: - version "3.29.2" - resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.29.2.tgz#c99f42513c80a75e2e94858add32896c860202ac" - integrity sha512-2G1ycU28Nh7OHT9rkXRLpCDP30MKH1dXJORZuBhtEhEW7pKwgPi77ImqlCWinouyE1PNepIOGZBOrE84DG7LyQ== +cytoscape-fcose@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz#e4d6f6490df4fab58ae9cea9e5c3ab8d7472f471" + integrity sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ== + dependencies: + cose-base "^2.2.0" + +cytoscape@^3.29.3: + version "3.32.0" + resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.32.0.tgz#34bc2402c9bc7457ab7d9492745f034b7bf47644" + integrity sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ== "d3-array@1 - 2": version "2.12.1" @@ -4389,7 +6246,7 @@ d3-zoom@3: d3-selection "2 - 3" d3-transition "2 - 3" -d3@^7.4.0, d3@^7.8.2: +d3@^7.9.0: version "7.9.0" resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d" integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA== @@ -4425,25 +6282,25 @@ d3@^7.4.0, d3@^7.8.2: d3-transition "3" d3-zoom "3" -dagre-d3-es@7.0.10: - version "7.0.10" - resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc" - integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A== +dagre-d3-es@7.0.11: + version "7.0.11" + resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.11.tgz#2237e726c0577bfe67d1a7cfd2265b9ab2c15c40" + integrity sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw== dependencies: - d3 "^7.8.2" + d3 "^7.9.0" lodash-es "^4.17.21" -dayjs@^1.11.7: - version "1.11.11" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.11.tgz#dfe0e9d54c5f8b68ccf8ca5f72ac603e7e5ed59e" - integrity sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg== +dayjs@^1.11.13: + version "1.11.13" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" + integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== debounce@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== -debug@2.6.9, debug@^2.6.0: +debug@2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -4464,6 +6321,13 @@ debug@4.3.4: dependencies: ms "2.1.2" +debug@^4.4.0: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + dependencies: + ms "^2.1.3" + decode-named-character-reference@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e" @@ -4483,7 +6347,7 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== -deepmerge@^4.0.0, deepmerge@^4.2.2, deepmerge@^4.3.1: +deepmerge@^4.0.0, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -4514,7 +6378,7 @@ define-lazy-prop@^2.0.0: resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== -define-properties@^1.1.3, define-properties@^1.2.1: +define-properties@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== @@ -4523,20 +6387,6 @@ define-properties@^1.1.3, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" -del@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/del/-/del-6.1.1.tgz#3b70314f1ec0aa325c6b14eb36b95786671edb7a" - integrity sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg== - dependencies: - globby "^11.0.1" - graceful-fs "^4.2.4" - is-glob "^4.0.1" - is-path-cwd "^2.2.0" - is-path-inside "^3.0.2" - p-map "^4.0.0" - rimraf "^3.0.2" - slash "^3.0.0" - delaunator@5: version "5.0.1" resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-5.0.1.tgz#39032b08053923e924d6094fe2cde1a99cc51278" @@ -4559,32 +6409,26 @@ dequal@^2.0.0: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== -des.js@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.1.0.tgz#1d37f5766f3bbff4ee9638e871a8768c173b81da" - integrity sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg== - dependencies: - inherits "^2.0.1" - minimalistic-assert "^1.0.0" - destroy@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + +detect-libc@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8" + integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== + detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -detect-port-alt@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" - integrity sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== - dependencies: - address "^1.0.1" - debug "^2.6.0" - detect-port@^1.5.1: version "1.6.1" resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.6.1.tgz#45e4073997c5f292b957cb678fb0bb8ed4250a67" @@ -4615,15 +6459,6 @@ diff@^5.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== -diffie-hellman@^5.0.0: - version "5.0.3" - resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875" - integrity sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg== - dependencies: - bn.js "^4.1.0" - miller-rabin "^4.0.0" - randombytes "^2.0.0" - dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -4643,29 +6478,26 @@ dns-packet@^5.2.2: dependencies: "@leichtgewicht/ip-codec" "^2.0.1" -docusaurus-plugin-image-zoom@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/docusaurus-plugin-image-zoom/-/docusaurus-plugin-image-zoom-1.0.1.tgz#17afec39f2e630cac50a4ed3a8bbdad8d0aa8b9d" - integrity sha512-96IpSKUx2RWy3db9aZ0s673OQo5DWgV9UVWouS+CPOSIVEdCWh6HKmWf6tB9rsoaiIF3oNn9keiyv6neEyKb1Q== +docusaurus-plugin-image-zoom@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/docusaurus-plugin-image-zoom/-/docusaurus-plugin-image-zoom-3.0.1.tgz#76095fdc288b58d351d19bf902bd3c0a3113ec09" + integrity sha512-mQrqA99VpoMQJNbi02qkWAMVNC4+kwc6zLLMNzraHAJlwn+HrlUmZSEDcTwgn+H4herYNxHKxveE2WsYy73eGw== dependencies: - medium-zoom "^1.0.6" + medium-zoom "^1.1.0" validate-peer-dependencies "^2.2.0" -docusaurus-plugin-openapi-docs@3.0.1, docusaurus-plugin-openapi-docs@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-3.0.1.tgz#954fdc4103d7e47133aede210a98353b3e0f0f99" - integrity sha512-6SRqwey/TXMNu2G02mbWgxrifhpjGOjDr30N+58AR0Ytgc+HXMqlPAUIvTe+e7sOBfAtBbiNlmOWv5KSYIjf3w== +docusaurus-plugin-openapi-docs@4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/docusaurus-plugin-openapi-docs/-/docusaurus-plugin-openapi-docs-4.4.0.tgz#010b2bfc57aeac1b62c41bf1ef386dcc52c5e91f" + integrity sha512-VFW0euAyM6i6U6Q2WrNXkp1LnxQFGszZbmloMFYrs1qwBjPLkuHfQ4OJMXGDsGcGl4zNDJ9cwODmJlmdwl1hwg== dependencies: "@apidevtools/json-schema-ref-parser" "^11.5.4" - "@docusaurus/plugin-content-docs" "^3.0.1" - "@docusaurus/utils" "^3.0.1" - "@docusaurus/utils-validation" "^3.0.1" "@redocly/openapi-core" "^1.10.5" + allof-merge "^0.6.6" chalk "^4.1.2" clsx "^1.1.1" fs-extra "^9.0.1" json-pointer "^0.6.2" - json-schema-merge-allof "^0.8.1" json5 "^2.2.3" lodash "^4.17.20" mustache "^4.2.0" @@ -4675,13 +6507,6 @@ docusaurus-plugin-openapi-docs@3.0.1, docusaurus-plugin-openapi-docs@^3.0.1: swagger2openapi "^7.0.8" xml-formatter "^2.6.1" -docusaurus-plugin-sass@^0.2.3: - version "0.2.5" - resolved "https://registry.yarnpkg.com/docusaurus-plugin-sass/-/docusaurus-plugin-sass-0.2.5.tgz#6bfb8a227ac6265be685dcbc24ba1989e27b8005" - integrity sha512-Z+D0fLFUKcFpM+bqSUmqKIU+vO+YF1xoEQh5hoFreg2eMf722+siwXDD+sqtwU8E4MvVpuvsQfaHwODNlxJAEg== - dependencies: - sass-loader "^10.1.1" - docusaurus-theme-github-codeblock@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/docusaurus-theme-github-codeblock/-/docusaurus-theme-github-codeblock-2.0.2.tgz#88b7044b81f9091330e8e4a07a1bdc9114a9fb93" @@ -4689,25 +6514,25 @@ docusaurus-theme-github-codeblock@^2.0.2: dependencies: "@docusaurus/types" "^3.0.0" -docusaurus-theme-openapi-docs@3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-3.0.1.tgz#49789c63377f294e624a9632eddb8265a421020f" - integrity sha512-tqypV91tC3wuWj9O+4n0M/e5AgHOeMT2nvPj1tjlPkC7/dLinZvpwQStT4YDUPYSoHRseqxd7lhivFQHcmlryg== +docusaurus-theme-openapi-docs@4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/docusaurus-theme-openapi-docs/-/docusaurus-theme-openapi-docs-4.4.0.tgz#601eb34d43fa49c6fe1418f3fed06e3044ce377f" + integrity sha512-wmc2b946rqBcdjgEHi6Up7e8orasYk5RnIUerTfmZ/Hi006I8FIjMnJEmHAF6t5PbFiiYnlkB6vYK0CC5xBnCQ== dependencies: - "@docusaurus/theme-common" "^3.0.1" "@hookform/error-message" "^2.0.1" "@reduxjs/toolkit" "^1.7.1" + allof-merge "^0.6.6" + buffer "^6.0.3" clsx "^1.1.1" copy-text-to-clipboard "^3.1.0" crypto-js "^4.1.1" - docusaurus-plugin-openapi-docs "^3.0.1" - docusaurus-plugin-sass "^0.2.3" file-saver "^2.0.5" lodash "^4.17.20" - node-polyfill-webpack-plugin "^2.0.1" + pako "^2.1.0" postman-code-generators "^1.10.1" postman-collection "^4.4.0" prism-react-renderer "^2.3.0" + process "^0.11.10" react-hook-form "^7.43.8" react-live "^4.0.0" react-magic-dropzone "^1.0.1" @@ -4715,9 +6540,11 @@ docusaurus-theme-openapi-docs@3.0.1: react-modal "^3.15.1" react-redux "^7.2.0" rehype-raw "^6.1.1" - sass "^1.58.1" - sass-loader "^13.3.2" - webpack "^5.61.0" + remark-gfm "3.0.1" + sass "^1.80.4" + sass-loader "^16.0.2" + unist-util-visit "^5.0.0" + url "^0.11.1" xml-formatter "^2.6.1" dom-converter@^0.2.0: @@ -4745,11 +6572,6 @@ dom-serializer@^2.0.0: domhandler "^5.0.2" entities "^4.2.0" -domain-browser@^4.22.0: - version "4.23.0" - resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-4.23.0.tgz#427ebb91efcb070f05cffdfb8a4e9a6c25f8c94b" - integrity sha512-ArzcM/II1wCCujdCNyQjXrAFwS4mrLh4C7DZWlaI8mdh7h3BfKdNd3bKXITfl2PT9FtfQqaGvhi1vPRQPimjGA== - domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" @@ -4769,10 +6591,12 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.0.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.5.tgz#2c6a113fc728682a0f55684b1388c58ddb79dc38" - integrity sha512-lwG+n5h8QNpxtyrJW/gJWckL+1/DQiYMX8f7t8Z2AZTPw1esVrqjI63i7Zc2Gz0aKzLVMYC1V1PL/ky+aY/NgA== +dompurify@^3.2.4: + version "3.2.6" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad" + integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ== + optionalDependencies: + "@types/trusted-types" "^2.0.7" domutils@^2.5.2, domutils@^2.8.0: version "2.8.0" @@ -4807,6 +6631,15 @@ dot-prop@^6.0.1: dependencies: is-obj "^2.0.0" +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" @@ -4827,23 +6660,10 @@ electron-to-chromium@^1.4.796: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.803.tgz#cf55808a5ee12e2a2778bbe8cdc941ef87c2093b" integrity sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g== -elkjs@^0.9.0: - version "0.9.3" - resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.9.3.tgz#16711f8ceb09f1b12b99e971b138a8384a529161" - integrity sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ== - -elliptic@^6.5.3, elliptic@^6.5.5: - version "6.5.7" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.7.tgz#8ec4da2cb2939926a1b9a73619d768207e647c8b" - integrity sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q== - dependencies: - bn.js "^4.11.9" - brorand "^1.1.0" - hash.js "^1.0.0" - hmac-drbg "^1.0.1" - inherits "^2.0.4" - minimalistic-assert "^1.0.1" - minimalistic-crypto-utils "^1.0.1" +electron-to-chromium@^1.5.149: + version "1.5.158" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz#e5f01fc7fdf810d9d223e30593e0839c306276d4" + integrity sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ== emoji-regex@^8.0.0: version "8.0.0" @@ -4890,6 +6710,14 @@ enhanced-resolve@^5.17.0: graceful-fs "^4.2.4" tapable "^2.2.0" +enhanced-resolve@^5.17.1: + version "5.18.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" + integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + entities@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" @@ -4914,6 +6742,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -4924,6 +6757,13 @@ es-module-lexer@^1.2.1: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.3.tgz#25969419de9c0b1fbe54279789023e8a9a788412" integrity sha512-i1gCgmR9dCl6Vil6UKPI/trA69s08g/syhiDK9TG0Nf1RJjjFI+AzoWW7sPufzkgYAn861skuCwJa0pIIHYxvg== +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es6-promise@^3.2.1: version "3.3.1" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" @@ -4934,6 +6774,11 @@ escalade@^3.1.1, escalade@^3.1.2: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + escape-goat@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-4.0.0.tgz#9424820331b510b0666b98f7873fe11ac4aa8081" @@ -5095,30 +6940,17 @@ eval@^0.1.8: "@types/node" "*" require-like ">= 0.1.1" -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - -eventemitter3@^4.0.0: +eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== -events@^3.2.0, events@^3.3.0: +events@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz#7fcbdb198dc71959432efe13842684e0525acb02" - integrity sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA== - dependencies: - md5.js "^1.3.4" - safe-buffer "^5.1.1" - -execa@^5.0.0: +execa@5.1.1, execa@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== @@ -5175,6 +7007,11 @@ express@^4.17.3: utils-merge "1.0.1" vary "~1.1.2" +exsolve@^1.0.1: + version "1.0.5" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.5.tgz#1f5b6b4fe82ad6b28a173ccb955a635d77859dcf" + integrity sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg== + extend-shallow@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" @@ -5229,13 +7066,6 @@ fast-safe-stringify@^2.0.7: resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== -fast-url-parser@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" - integrity sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ== - dependencies: - punycode "^1.3.2" - fastq@^1.6.0: version "1.17.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" @@ -5271,6 +7101,13 @@ feed@^4.2.2: dependencies: xml-js "^1.6.11" +figures@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" + integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== + dependencies: + escape-string-regexp "^1.0.5" + file-loader@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d" @@ -5289,11 +7126,6 @@ file-type@3.9.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" integrity sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA== -filesize@^8.0.6: - version "8.0.7" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-8.0.7.tgz#695e70d80f4e47012c132d57a059e80c6b580bd8" - integrity sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ== - fill-range@^7.1.1: version "7.1.1" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" @@ -5301,11 +7133,6 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -filter-obj@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/filter-obj/-/filter-obj-2.0.2.tgz#fff662368e505d69826abb113f0f6a98f56e9d5f" - integrity sha512-lO3ttPjHZRfjMcxWKb1j1eDhTFsu4meeR3lnMcnBFhk6RuLhvEiuALu2TlfL310ph4lCYYwgF/ElIjdP739tdg== - finalhandler@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" @@ -5327,21 +7154,6 @@ find-cache-dir@^4.0.0: common-path-prefix "^3.0.0" pkg-dir "^7.0.0" -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - find-up@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-6.3.0.tgz#2abab3d3280b2dc7ac10199ef324c4e002c8c790" @@ -5355,18 +7167,11 @@ flat@^5.0.2: resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== -follow-redirects@^1.0.0, follow-redirects@^1.14.7: +follow-redirects@^1.0.0: version "1.15.6" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== -for-each@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.3.tgz#69b447e88a0a5d32c3e7084f3f1710034b21376e" - integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== - dependencies: - is-callable "^1.1.3" - foreach@^2.0.4: version "2.0.6" resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.6.tgz#87bcc8a1a0e74000ff2bf9802110708cfb02eb6e" @@ -5380,25 +7185,6 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" -fork-ts-checker-webpack-plugin@^6.5.0: - version "6.5.3" - resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.3.tgz#eda2eff6e22476a2688d10661688c47f611b37f3" - integrity sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ== - dependencies: - "@babel/code-frame" "^7.8.3" - "@types/json-schema" "^7.0.5" - chalk "^4.1.0" - chokidar "^3.4.2" - cosmiconfig "^6.0.0" - deepmerge "^4.2.2" - fs-extra "^9.0.0" - glob "^7.1.6" - memfs "^3.1.2" - minimatch "^3.0.4" - schema-utils "2.7.0" - semver "^7.3.2" - tapable "^1.0.0" - form-data-encoder@^2.1.2: version "2.1.4" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" @@ -5438,7 +7224,7 @@ fs-extra@^11.1.1, fs-extra@^11.2.0: jsonfile "^6.0.1" universalify "^2.0.0" -fs-extra@^9.0.0, fs-extra@^9.0.1: +fs-extra@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -5489,11 +7275,35 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-stream@^5.1.0: version "5.2.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" @@ -5541,7 +7351,7 @@ glob@^10.3.10: minipass "^7.1.2" path-scurry "^1.11.1" -glob@^7.0.0, glob@^7.1.3, glob@^7.1.6: +glob@^7.0.0, glob@^7.1.3: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -5560,28 +7370,17 @@ global-dirs@^3.0.0: dependencies: ini "2.0.0" -global-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" - integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== - dependencies: - global-prefix "^3.0.0" - -global-prefix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" - integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== - dependencies: - ini "^1.3.5" - kind-of "^6.0.2" - which "^1.3.1" - globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globby@^11.0.1, globby@^11.0.4, globby@^11.1.0: +globals@^15.14.0: + version "15.15.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-15.15.0.tgz#7c4761299d41c32b075715a4ce1ede7897ff72a8" + integrity sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg== + +globby@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== @@ -5611,6 +7410,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + got@^12.1.0: version "12.6.1" resolved "https://registry.yarnpkg.com/got/-/got-12.6.1.tgz#8869560d1383353204b5a9435f782df9c091f549" @@ -5662,6 +7466,11 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" +hachure-fill@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc" + integrity sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg== + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -5694,44 +7503,17 @@ has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== -has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" - integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== - dependencies: - has-symbols "^1.0.3" +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== has-yarn@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-3.0.0.tgz#c3c21e559730d1d3b57e28af1f30d06fac38147d" integrity sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA== -hash-base@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.1.0.tgz#55c381d9e06e1d2997a883b4a3fddfe7f0d3af33" - integrity sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA== - dependencies: - inherits "^2.0.4" - readable-stream "^3.6.0" - safe-buffer "^5.2.0" - -hash-base@~3.0: - version "3.0.4" - resolved "https://registry.yarnpkg.com/hash-base/-/hash-base-3.0.4.tgz#5fc8686847ecd73499403319a6b0a3f3f6ae4918" - integrity sha512-EeeoJKjTyt868liAlVmcv2ZsUfGHlE3Q+BICOXcZiwN3osr5Q/zFGYmTJpoIzuaSTAwndFy+GqhEwlU4L3j4Ow== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - -hash.js@^1.0.0, hash.js@^1.0.3: - version "1.1.7" - resolved "https://registry.yarnpkg.com/hash.js/-/hash.js-1.1.7.tgz#0babca538e8d4ee4a0f8988d68866537a003cf42" - integrity sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA== - dependencies: - inherits "^2.0.3" - minimalistic-assert "^1.0.1" - -hasown@^2.0.0: +hasown@^2.0.0, hasown@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== @@ -5966,15 +7748,6 @@ history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" -hmac-drbg@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" - integrity sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg== - dependencies: - hash.js "^1.0.3" - minimalistic-assert "^1.0.0" - minimalistic-crypto-utils "^1.0.1" - hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" @@ -6043,10 +7816,10 @@ html-void-elements@^3.0.0: resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== -html-webpack-plugin@^5.5.3: - version "5.6.0" - resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.0.tgz#50a8fa6709245608cb00e811eacecb8e0d7b7ea0" - integrity sha512-iwaY4wzbe48AfKLZ/Cc8k0L+FKG6oSNRaZ8x5A/T/IVDGyXcbHncM9TdDa93wn0FsSm82FhTKW7f3vS61thXAw== +html-webpack-plugin@^5.6.0: + version "5.6.3" + resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz#a31145f0fee4184d53a794f9513147df1e653685" + integrity sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg== dependencies: "@types/html-minifier-terser" "^6.0.0" html-minifier-terser "^6.0.2" @@ -6148,11 +7921,6 @@ http2-wrapper@^2.1.10: quick-lru "^5.1.1" resolve-alpn "^1.2.0" -https-browserify@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" - integrity sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg== - https-proxy-agent@5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -6195,24 +7963,22 @@ ignore@^5.2.0, ignore@^5.2.4: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== -image-size@^1.0.2: - version "1.2.1" - resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.2.1.tgz#ee118aedfe666db1a6ee12bed5821cde3740276d" - integrity sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw== - dependencies: - queue "6.0.2" +image-size@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/image-size/-/image-size-2.0.2.tgz#84a7b43704db5736f364bf0d1b029821299b4bdc" + integrity sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w== -immer@^9.0.21, immer@^9.0.7: +immer@^9.0.21: version "9.0.21" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.21.tgz#1e025ea31a40f24fb064f1fef23e931496330176" integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== -immutable@^4.0.0: - version "4.3.6" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447" - integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ== +immutable@^5.0.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.2.tgz#e8169476414505e5a4fa650107b65e1227d16d4b" + integrity sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ== -import-fresh@^3.1.0, import-fresh@^3.3.0: +import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== @@ -6235,10 +8001,10 @@ indent-string@^4.0.0: resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== -infima@0.2.0-alpha.43: - version "0.2.0-alpha.43" - resolved "https://registry.yarnpkg.com/infima/-/infima-0.2.0-alpha.43.tgz#f7aa1d7b30b6c08afef441c726bac6150228cbe0" - integrity sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ== +infima@0.2.0-alpha.45: + version "0.2.0-alpha.45" + resolved "https://registry.yarnpkg.com/infima/-/infima-0.2.0-alpha.45.tgz#542aab5a249274d81679631b492973dd2c1e7466" + integrity sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw== inflight@^1.0.4: version "1.0.6" @@ -6248,7 +8014,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -6263,7 +8029,7 @@ ini@2.0.0: resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== -ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: +ini@^1.3.4, ini@~1.3.0: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== @@ -6323,14 +8089,6 @@ is-alphanumerical@^2.0.0: is-alphabetical "^2.0.0" is-decimal "^2.0.0" -is-arguments@^1.0.4: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" - integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" @@ -6348,11 +8106,6 @@ is-buffer@^2.0.0: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-callable@^1.1.3: - version "1.2.7" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" - integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== - is-ci@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-3.0.1.tgz#db6ecbed1bd659c43dac0f45661e7674103d1867" @@ -6392,13 +8145,6 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== -is-generator-function@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" - integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== - dependencies: - has-tostringtag "^1.0.0" - is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -6419,14 +8165,6 @@ is-installed-globally@^0.4.0: global-dirs "^3.0.0" is-path-inside "^3.0.2" -is-nan@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" - integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== - dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - is-npm@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-6.0.0.tgz#b59e75e8915543ca5d881ecff864077cba095261" @@ -6447,11 +8185,6 @@ is-obj@^2.0.0: resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== -is-path-cwd@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" - integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== - is-path-inside@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" @@ -6486,23 +8219,11 @@ is-regexp@^1.0.0: resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" integrity sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA== -is-root@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" - integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== - is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== -is-typed-array@^1.1.3: - version "1.1.13" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" - integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== - dependencies: - which-typed-array "^1.1.14" - is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -6585,7 +8306,7 @@ jiti@^1.20.0, jiti@^1.21.0: resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== -joi@^17.6.0, joi@^17.9.2: +joi@^17.9.2: version "17.13.1" resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.1.tgz#9c7b53dc3b44dd9ae200255cc3b398874918a6ca" integrity sha512-vaBlIKCyo4FCUtCm7Eu4QZd/q02bWcxfUO6YSXAZOWF6gzcLBeba8kwotUdYJjDLW8Cz8RywsSOqiNJZW0mNvg== @@ -6626,16 +8347,31 @@ jsesc@^2.5.1: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + jsesc@~0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== +jsesc@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" + integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== + json-buffer@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== +json-crawl@^0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/json-crawl/-/json-crawl-0.5.3.tgz#3a2e1d308d4fc5a444902f1f94f4a9e03d584c6b" + integrity sha512-BEjjCw8c7SxzNK4orhlWD5cXQh8vCk2LqDr4WgQq4CV+5dvopeYwt1Tskg67SuSLKvoFH5g0yuYtg7rcfKV6YA== + json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" @@ -6655,7 +8391,7 @@ json-schema-compare@^0.2.2: dependencies: lodash "^4.17.4" -json-schema-merge-allof@0.8.1, json-schema-merge-allof@^0.8.1: +json-schema-merge-allof@0.8.1: version "0.8.1" resolved "https://registry.yarnpkg.com/json-schema-merge-allof/-/json-schema-merge-allof-0.8.1.tgz#ed2828cdd958616ff74f932830a26291789eaaf2" integrity sha512-CTUKmIlPJbsWfzRRnOXz+0MjIqvnleIXwFTzz+t9T86HnYX/Rozria6ZVGLktAU9e+NygNljveP+yxqtQp/Q4w== @@ -6702,7 +8438,7 @@ keyv@^4.5.3: dependencies: json-buffer "3.0.1" -khroma@^2.0.0: +khroma@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.1.0.tgz#45f2ce94ce231a437cf5b63c2e886e6eb42bbbb1" integrity sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw== @@ -6722,10 +8458,21 @@ kleur@^4.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== -klona@^2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.6.tgz#85bffbf819c03b2f53270412420a4555ef882e22" - integrity sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA== +kolorist@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" + integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== + +langium@3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/langium/-/langium-3.3.1.tgz#da745a40d5ad8ee565090fed52eaee643be4e591" + integrity sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w== + dependencies: + chevrotain "~11.0.3" + chevrotain-allstar "~0.3.0" + vscode-languageserver "~9.0.1" + vscode-languageserver-textdocument "~1.0.11" + vscode-uri "~3.0.8" latest-version@^7.0.0: version "7.0.0" @@ -6747,11 +8494,84 @@ layout-base@^1.0.0: resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-1.0.2.tgz#1291e296883c322a9dd4c5dd82063721b53e26e2" integrity sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg== +layout-base@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285" + integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg== + leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== +lightningcss-darwin-arm64@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz#3d47ce5e221b9567c703950edf2529ca4a3700ae" + integrity sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ== + +lightningcss-darwin-x64@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz#e81105d3fd6330860c15fe860f64d39cff5fbd22" + integrity sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA== + +lightningcss-freebsd-x64@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz#a0e732031083ff9d625c5db021d09eb085af8be4" + integrity sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig== + +lightningcss-linux-arm-gnueabihf@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz#1f5ecca6095528ddb649f9304ba2560c72474908" + integrity sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q== + +lightningcss-linux-arm64-gnu@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz#eee7799726103bffff1e88993df726f6911ec009" + integrity sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw== + +lightningcss-linux-arm64-musl@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz#f2e4b53f42892feeef8f620cbb889f7c064a7dfe" + integrity sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ== + +lightningcss-linux-x64-gnu@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz#2fc7096224bc000ebb97eea94aea248c5b0eb157" + integrity sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw== + +lightningcss-linux-x64-musl@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz#66dca2b159fd819ea832c44895d07e5b31d75f26" + integrity sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ== + +lightningcss-win32-arm64-msvc@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz#7d8110a19d7c2d22bfdf2f2bb8be68e7d1b69039" + integrity sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA== + +lightningcss-win32-x64-msvc@1.30.1: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz#fd7dd008ea98494b85d24b4bea016793f2e0e352" + integrity sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg== + +lightningcss@^1.27.0: + version "1.30.1" + resolved "https://registry.yarnpkg.com/lightningcss/-/lightningcss-1.30.1.tgz#78e979c2d595bfcb90d2a8c0eb632fe6c5bfed5d" + integrity sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg== + dependencies: + detect-libc "^2.0.3" + optionalDependencies: + lightningcss-darwin-arm64 "1.30.1" + lightningcss-darwin-x64 "1.30.1" + lightningcss-freebsd-x64 "1.30.1" + lightningcss-linux-arm-gnueabihf "1.30.1" + lightningcss-linux-arm64-gnu "1.30.1" + lightningcss-linux-arm64-musl "1.30.1" + lightningcss-linux-x64-gnu "1.30.1" + lightningcss-linux-x64-musl "1.30.1" + lightningcss-win32-arm64-msvc "1.30.1" + lightningcss-win32-x64-msvc "1.30.1" + lilconfig@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" @@ -6791,25 +8611,14 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -loader-utils@^3.2.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.3.1.tgz#735b9a19fd63648ca7adbd31c2327dfe281304e5" - integrity sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg== - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== +local-pkg@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-1.1.1.tgz#f5fe74a97a3bd3c165788ee08ca9fbe998dc58dd" + integrity sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg== dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" + mlly "^1.7.4" + pkg-types "^2.0.1" + quansync "^0.2.8" locate-path@^7.1.0: version "7.2.0" @@ -6818,7 +8627,7 @@ locate-path@^7.1.0: dependencies: p-locate "^6.0.0" -lodash-es@^4.17.21: +lodash-es@4.17.21, lodash-es@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== @@ -6896,19 +8705,27 @@ markdown-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-2.0.0.tgz#34bebc83e9938cae16e0e017e4a9814a8330d3c4" integrity sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q== +markdown-table@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-2.0.0.tgz#194a90ced26d31fe753d8b9434430214c011865b" + integrity sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A== + dependencies: + repeat-string "^1.0.0" + markdown-table@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== -md5.js@^1.3.4: - version "1.3.5" - resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f" - integrity sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg== - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - safe-buffer "^5.1.2" +marked@^15.0.7: + version "15.0.12" + resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e" + integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA== + +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== mdast-util-definitions@^5.0.0: version "5.1.2" @@ -6933,6 +8750,16 @@ mdast-util-directive@^3.0.0: stringify-entities "^4.0.0" unist-util-visit-parents "^6.0.0" +mdast-util-find-and-replace@^2.0.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz#cc2b774f7f3630da4bd592f61966fecade8b99b1" + integrity sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw== + dependencies: + "@types/mdast" "^3.0.0" + escape-string-regexp "^5.0.0" + unist-util-is "^5.0.0" + unist-util-visit-parents "^5.0.0" + mdast-util-find-and-replace@^3.0.0, mdast-util-find-and-replace@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.1.tgz#a6fc7b62f0994e973490e45262e4bc07607b04e0" @@ -6943,7 +8770,7 @@ mdast-util-find-and-replace@^3.0.0, mdast-util-find-and-replace@^3.0.1: unist-util-is "^6.0.0" unist-util-visit-parents "^6.0.0" -mdast-util-from-markdown@^1.0.0, mdast-util-from-markdown@^1.1.0, mdast-util-from-markdown@^1.2.0, mdast-util-from-markdown@^1.3.0: +mdast-util-from-markdown@^1.0.0, mdast-util-from-markdown@^1.1.0, mdast-util-from-markdown@^1.2.0: version "1.3.1" resolved "https://registry.yarnpkg.com/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz#9421a5a247f10d31d2faed2a30df5ec89ceafcf0" integrity sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww== @@ -6991,6 +8818,16 @@ mdast-util-frontmatter@^2.0.0: mdast-util-to-markdown "^2.0.0" micromark-extension-frontmatter "^2.0.0" +mdast-util-gfm-autolink-literal@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.3.tgz#67a13abe813d7eba350453a5333ae1bc0ec05c06" + integrity sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA== + dependencies: + "@types/mdast" "^3.0.0" + ccount "^2.0.0" + mdast-util-find-and-replace "^2.0.0" + micromark-util-character "^1.0.0" + mdast-util-gfm-autolink-literal@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.0.tgz#5baf35407421310a08e68c15e5d8821e8898ba2a" @@ -7002,6 +8839,15 @@ mdast-util-gfm-autolink-literal@^2.0.0: mdast-util-find-and-replace "^3.0.0" micromark-util-character "^2.0.0" +mdast-util-gfm-footnote@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-1.0.2.tgz#ce5e49b639c44de68d5bf5399877a14d5020424e" + integrity sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.3.0" + micromark-util-normalize-identifier "^1.0.0" + mdast-util-gfm-footnote@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.0.0.tgz#25a1753c7d16db8bfd53cd84fe50562bd1e6d6a9" @@ -7013,6 +8859,14 @@ mdast-util-gfm-footnote@^2.0.0: mdast-util-to-markdown "^2.0.0" micromark-util-normalize-identifier "^2.0.0" +mdast-util-gfm-strikethrough@^1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-1.0.3.tgz#5470eb105b483f7746b8805b9b989342085795b7" + integrity sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.3.0" + mdast-util-gfm-strikethrough@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz#d44ef9e8ed283ac8c1165ab0d0dfd058c2764c16" @@ -7022,6 +8876,16 @@ mdast-util-gfm-strikethrough@^2.0.0: mdast-util-from-markdown "^2.0.0" mdast-util-to-markdown "^2.0.0" +mdast-util-gfm-table@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-1.0.7.tgz#3552153a146379f0f9c4c1101b071d70bbed1a46" + integrity sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg== + dependencies: + "@types/mdast" "^3.0.0" + markdown-table "^3.0.0" + mdast-util-from-markdown "^1.0.0" + mdast-util-to-markdown "^1.3.0" + mdast-util-gfm-table@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz#7a435fb6223a72b0862b33afbd712b6dae878d38" @@ -7033,6 +8897,14 @@ mdast-util-gfm-table@^2.0.0: mdast-util-from-markdown "^2.0.0" mdast-util-to-markdown "^2.0.0" +mdast-util-gfm-task-list-item@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-1.0.2.tgz#b280fcf3b7be6fd0cc012bbe67a59831eb34097b" + integrity sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-to-markdown "^1.3.0" + mdast-util-gfm-task-list-item@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz#e68095d2f8a4303ef24094ab642e1047b991a936" @@ -7043,6 +8915,19 @@ mdast-util-gfm-task-list-item@^2.0.0: mdast-util-from-markdown "^2.0.0" mdast-util-to-markdown "^2.0.0" +mdast-util-gfm@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz#e92f4d8717d74bdba6de57ed21cc8b9552e2d0b6" + integrity sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg== + dependencies: + mdast-util-from-markdown "^1.0.0" + mdast-util-gfm-autolink-literal "^1.0.0" + mdast-util-gfm-footnote "^1.0.0" + mdast-util-gfm-strikethrough "^1.0.0" + mdast-util-gfm-table "^1.0.0" + mdast-util-gfm-task-list-item "^1.0.0" + mdast-util-to-markdown "^1.0.0" + mdast-util-gfm@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-3.0.0.tgz#3f2aecc879785c3cb6a81ff3a243dc11eca61095" @@ -7277,12 +9162,12 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== -medium-zoom@^1.0.6: +medium-zoom@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/medium-zoom/-/medium-zoom-1.1.0.tgz#6efb6bbda861a02064ee71a2617a8dc4381ecc71" integrity sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ== -memfs@^3.1.2, memfs@^3.4.3: +memfs@^3.4.3: version "3.6.0" resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.6.0.tgz#d7a2110f86f79dd950a8b6df6d57bc984aa185f6" integrity sha512-EGowvkkgbMcIChjMTMkESFDbZeSh8xZ7kNSF0hAiAN4Jh6jgHCRS0Ga/+C8y6Au+oqpezRHCfPsmJ2+DwAgiwQ== @@ -7309,31 +9194,31 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@^10.4.0, mermaid@^10.9.1: - version "10.9.1" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.1.tgz#5f582c23f3186c46c6aa673e59eeb46d741b2ea6" - integrity sha512-Mx45Obds5W1UkW1nv/7dHRsbfMM1aOKA2+Pxs/IGHNonygDHwmng8xTHyS9z4KWVi0rbko8gjiBmuwwXQ7tiNA== +mermaid@>=11.6.0: + version "11.6.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.6.0.tgz#eee45cdc3087be561a19faf01745596d946bb575" + integrity sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg== dependencies: - "@braintree/sanitize-url" "^6.0.1" - "@types/d3-scale" "^4.0.3" - "@types/d3-scale-chromatic" "^3.0.0" - cytoscape "^3.28.1" + "@braintree/sanitize-url" "^7.0.4" + "@iconify/utils" "^2.1.33" + "@mermaid-js/parser" "^0.4.0" + "@types/d3" "^7.4.3" + cytoscape "^3.29.3" cytoscape-cose-bilkent "^4.1.0" - d3 "^7.4.0" + cytoscape-fcose "^2.2.0" + d3 "^7.9.0" d3-sankey "^0.12.3" - dagre-d3-es "7.0.10" - dayjs "^1.11.7" - dompurify "^3.0.5" - elkjs "^0.9.0" + dagre-d3-es "7.0.11" + dayjs "^1.11.13" + dompurify "^3.2.4" katex "^0.16.9" - khroma "^2.0.0" + khroma "^2.1.0" lodash-es "^4.17.21" - mdast-util-from-markdown "^1.3.0" - non-layered-tidy-tree-layout "^2.0.2" - stylis "^4.1.3" + marked "^15.0.7" + roughjs "^4.6.6" + stylis "^4.3.6" ts-dedent "^2.2.0" - uuid "^9.0.0" - web-worker "^1.2.0" + uuid "^11.1.0" methods@~1.1.2: version "1.1.2" @@ -7407,6 +9292,16 @@ micromark-extension-frontmatter@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-extension-gfm-autolink-literal@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.5.tgz#5853f0e579bbd8ef9e39a7c0f0f27c5a063a66e7" + integrity sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg== + dependencies: + micromark-util-character "^1.0.0" + micromark-util-sanitize-uri "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + micromark-extension-gfm-autolink-literal@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.0.0.tgz#f1e50b42e67d441528f39a67133eddde2bbabfd9" @@ -7417,6 +9312,20 @@ micromark-extension-gfm-autolink-literal@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-extension-gfm-footnote@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-1.1.2.tgz#05e13034d68f95ca53c99679040bc88a6f92fe2e" + integrity sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q== + dependencies: + micromark-core-commonmark "^1.0.0" + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-normalize-identifier "^1.0.0" + micromark-util-sanitize-uri "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-extension-gfm-footnote@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.0.0.tgz#91afad310065a94b636ab1e9dab2c60d1aab953c" @@ -7431,6 +9340,18 @@ micromark-extension-gfm-footnote@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-extension-gfm-strikethrough@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-1.0.7.tgz#c8212c9a616fa3bf47cb5c711da77f4fdc2f80af" + integrity sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw== + dependencies: + micromark-util-chunked "^1.0.0" + micromark-util-classify-character "^1.0.0" + micromark-util-resolve-all "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-extension-gfm-strikethrough@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.0.0.tgz#6917db8e320da70e39ffbf97abdbff83e6783e61" @@ -7443,6 +9364,17 @@ micromark-extension-gfm-strikethrough@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-extension-gfm-table@^1.0.0: + version "1.0.7" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-1.0.7.tgz#dcb46074b0c6254c3fc9cc1f6f5002c162968008" + integrity sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-extension-gfm-table@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.0.0.tgz#2cf3fe352d9e089b7ef5fff003bdfe0da29649b7" @@ -7454,6 +9386,13 @@ micromark-extension-gfm-table@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-extension-gfm-tagfilter@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-1.0.2.tgz#aa7c4dd92dabbcb80f313ebaaa8eb3dac05f13a7" + integrity sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g== + dependencies: + micromark-util-types "^1.0.0" + micromark-extension-gfm-tagfilter@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz#f26d8a7807b5985fba13cf61465b58ca5ff7dc57" @@ -7461,6 +9400,17 @@ micromark-extension-gfm-tagfilter@^2.0.0: dependencies: micromark-util-types "^2.0.0" +micromark-extension-gfm-task-list-item@^1.0.0: + version "1.0.5" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-1.0.5.tgz#b52ce498dc4c69b6a9975abafc18f275b9dde9f4" + integrity sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ== + dependencies: + micromark-factory-space "^1.0.0" + micromark-util-character "^1.0.0" + micromark-util-symbol "^1.0.0" + micromark-util-types "^1.0.0" + uvu "^0.5.0" + micromark-extension-gfm-task-list-item@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.0.1.tgz#ee8b208f1ced1eb9fb11c19a23666e59d86d4838" @@ -7472,6 +9422,20 @@ micromark-extension-gfm-task-list-item@^2.0.0: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" +micromark-extension-gfm@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-2.0.3.tgz#e517e8579949a5024a493e49204e884aa74f5acf" + integrity sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ== + dependencies: + micromark-extension-gfm-autolink-literal "^1.0.0" + micromark-extension-gfm-footnote "^1.0.0" + micromark-extension-gfm-strikethrough "^1.0.0" + micromark-extension-gfm-table "^1.0.0" + micromark-extension-gfm-tagfilter "^1.0.0" + micromark-extension-gfm-task-list-item "^1.0.0" + micromark-util-combine-extensions "^1.0.0" + micromark-util-types "^1.0.0" + micromark-extension-gfm@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz#3e13376ab95dd7a5cfd0e29560dfe999657b3c5b" @@ -8026,14 +9990,6 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: braces "^3.0.3" picomatch "^2.3.1" -miller-rabin@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d" - integrity sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA== - dependencies: - bn.js "^4.0.0" - brorand "^1.0.1" - mime-db@1.48.0: version "1.48.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" @@ -8097,25 +10053,20 @@ mimic-response@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-4.0.0.tgz#35468b19e7c75d10f5165ea25e75a5ceea7cf70f" integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== -mini-css-extract-plugin@^2.7.6: - version "2.9.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz#c73a1327ccf466f69026ac22a8e8fd707b78a235" - integrity sha512-Zs1YsZVfemekSZG+44vBsYTLQORkPMwnlv+aehcxK/NLKC+EGhDB39/YePYYqx/sTk6NnYpuqikhSn7+JIevTA== +mini-css-extract-plugin@^2.9.1: + version "2.9.2" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz#966031b468917a5446f4c24a80854b2947503c5b" + integrity sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w== dependencies: schema-utils "^4.0.0" tapable "^2.2.1" -minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: +minimalistic-assert@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimalistic-crypto-utils@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" - integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== - -minimatch@3.1.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1: +minimatch@3.1.2, minimatch@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -8136,7 +10087,7 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" -minimist@^1.2.0, minimist@^1.2.5: +minimist@^1.2.0: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -8151,6 +10102,16 @@ mkdirp-classic@^0.5.2: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== +mlly@^1.7.4: + version "1.7.4" + resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f" + integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw== + dependencies: + acorn "^8.14.0" + pathe "^2.0.1" + pkg-types "^1.3.0" + ufo "^1.5.4" + mri@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" @@ -8171,7 +10132,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3: +ms@2.1.3, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -8221,6 +10182,11 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + node-emoji@^2.1.0: version "2.1.3" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-2.1.3.tgz#93cfabb5cc7c3653aa52f29d6ffb7927d8047c06" @@ -8257,37 +10223,6 @@ node-forge@^1: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== -node-polyfill-webpack-plugin@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/node-polyfill-webpack-plugin/-/node-polyfill-webpack-plugin-2.0.1.tgz#141d86f177103a8517c71d99b7c6a46edbb1bb58" - integrity sha512-ZUMiCnZkP1LF0Th2caY6J/eKKoA0TefpoVa68m/LQU1I/mE8rGt4fNYGgNuCcK+aG8P8P43nbeJ2RqJMOL/Y1A== - dependencies: - assert "^2.0.0" - browserify-zlib "^0.2.0" - buffer "^6.0.3" - console-browserify "^1.2.0" - constants-browserify "^1.0.0" - crypto-browserify "^3.12.0" - domain-browser "^4.22.0" - events "^3.3.0" - filter-obj "^2.0.2" - https-browserify "^1.0.0" - os-browserify "^0.3.0" - path-browserify "^1.0.1" - process "^0.11.10" - punycode "^2.1.1" - querystring-es3 "^0.2.1" - readable-stream "^4.0.0" - stream-browserify "^3.0.0" - stream-http "^3.2.0" - string_decoder "^1.3.0" - timers-browserify "^2.0.12" - tty-browserify "^0.0.1" - type-fest "^2.14.0" - url "^0.11.0" - util "^0.12.4" - vm-browserify "^1.1.2" - node-readfiles@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/node-readfiles/-/node-readfiles-0.2.0.tgz#dbbd4af12134e2e635c245ef93ffcf6f60673a5d" @@ -8300,10 +10235,10 @@ node-releases@^2.0.14: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== -non-layered-tidy-tree-layout@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz#57d35d13c356643fc296a55fb11ac15e74da7804" - integrity sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw== +node-releases@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" @@ -8339,6 +10274,14 @@ nth-check@^2.0.1: dependencies: boolbase "^1.0.0" +null-loader@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/null-loader/-/null-loader-4.0.1.tgz#8e63bd3a2dd3c64236a4679428632edd0a6dbc6a" + integrity sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg== + dependencies: + loader-utils "^2.0.0" + schema-utils "^3.0.0" + oas-kit-common@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/oas-kit-common/-/oas-kit-common-1.0.8.tgz#6d8cacf6e9097967a4c7ea8bcbcbd77018e1f535" @@ -8412,20 +10355,17 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== -object-is@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" - integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" +object-inspect@^1.13.3: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.0, object.assign@^4.1.4: +object.assign@^4.1.0: version "4.1.5" resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.5.tgz#3a833f9ab7fdb80fc9e8d2300c803d216d8fdbb0" integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== @@ -8502,29 +10442,15 @@ opener@^1.5.2: resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== -os-browserify@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/os-browserify/-/os-browserify-0.3.0.tgz#854373c7f5c2315914fc9bfc6bd8238fdda1ec27" - integrity sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A== - p-cancelable@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-3.0.0.tgz#63826694b54d61ca1c20ebcb6d3ecf5e14cd8050" integrity sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw== -p-limit@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + integrity sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow== p-limit@^4.0.0: version "4.0.0" @@ -8533,20 +10459,6 @@ p-limit@^4.0.0: dependencies: yocto-queue "^1.0.0" -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - p-locate@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-6.0.0.tgz#3da9a49d4934b901089dca3302fa65dc5a05c04f" @@ -8561,6 +10473,14 @@ p-map@^4.0.0: dependencies: aggregate-error "^3.0.0" +p-queue@^6.6.2: + version "6.6.2" + resolved "https://registry.yarnpkg.com/p-queue/-/p-queue-6.6.2.tgz#2068a9dcf8e67dd0ec3e7a2bcb76810faa85e426" + integrity sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ== + dependencies: + eventemitter3 "^4.0.4" + p-timeout "^3.2.0" + p-retry@^4.5.0: version "4.6.2" resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" @@ -8569,10 +10489,12 @@ p-retry@^4.5.0: "@types/retry" "0.12.0" retry "^0.13.1" -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +p-timeout@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-3.2.0.tgz#c7e17abc971d2a7962ef83626b35d635acf23dfe" + integrity sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg== + dependencies: + p-finally "^1.0.0" package-json@^8.1.0: version "8.1.1" @@ -8584,10 +10506,15 @@ package-json@^8.1.0: registry-url "^6.0.0" semver "^7.3.7" -pako@~1.0.5: - version "1.0.11" - resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" - integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== +package-manager-detector@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.3.0.tgz#b42d641c448826e03c2b354272456a771ce453c0" + integrity sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ== + +pako@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" + integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== param-case@^3.0.4: version "3.0.4" @@ -8604,18 +10531,6 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-asn1@^5.0.0, parse-asn1@^5.1.7: - version "5.1.7" - resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.7.tgz#73cdaaa822125f9647165625eb45f8a051d2df06" - integrity sha512-CTM5kuWR3sx9IFamcl5ErfPl6ea/N8IYwiJ+vpeB2g+1iknv7zBl5uPwbMbRVznRVbrNY6lGuDoE5b30grmbqg== - dependencies: - asn1.js "^4.10.1" - browserify-aes "^1.2.0" - evp_bytestokey "^1.0.3" - hash-base "~3.0" - pbkdf2 "^3.1.2" - safe-buffer "^5.2.1" - parse-entities@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-4.0.1.tgz#4e2a01111fb1c986549b944af39eeda258fc9e4e" @@ -8630,7 +10545,7 @@ parse-entities@^4.0.0: is-decimal "^2.0.0" is-hexadecimal "^2.0.0" -parse-json@^5.0.0, parse-json@^5.2.0: +parse-json@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== @@ -8683,15 +10598,10 @@ path-browserify@1.0.1, path-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-data-parser@0.1.0, path-data-parser@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/path-data-parser/-/path-data-parser-0.1.0.tgz#8f5ba5cc70fc7becb3dcefaea08e2659aba60b8c" + integrity sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w== path-exists@^5.0.0: version "5.0.0" @@ -8743,10 +10653,10 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== -path-to-regexp@2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" - integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== +path-to-regexp@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" + integrity sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw== path-to-regexp@^1.7.0: version "1.8.0" @@ -8768,16 +10678,10 @@ path@0.12.7: process "^0.11.1" util "^0.10.3" -pbkdf2@^3.0.3, pbkdf2@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.2.tgz#dd822aa0887580e52f1a039dc3eda108efae3075" - integrity sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA== - dependencies: - create-hash "^1.1.2" - create-hmac "^1.1.4" - ripemd160 "^2.0.1" - safe-buffer "^5.0.1" - sha.js "^2.4.8" +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== pend@~1.2.0: version "1.2.0" @@ -8798,6 +10702,11 @@ picocolors@^1.0.0, picocolors@^1.0.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.1.tgz#a8ad579b571952f0e5d25892de5445bcfe25aaa1" integrity sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew== +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -8820,22 +10729,48 @@ pkg-dir@^7.0.0: dependencies: find-up "^6.3.0" -pkg-up@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" - integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== +pkg-types@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== dependencies: - find-up "^3.0.0" + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" + +pkg-types@^2.0.1: + version "2.1.0" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-2.1.0.tgz#70c9e1b9c74b63fdde749876ee0aa007ea9edead" + integrity sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A== + dependencies: + confbox "^0.2.1" + exsolve "^1.0.1" + pathe "^2.0.3" pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== -possible-typed-array-names@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" - integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== +points-on-curve@0.2.0, points-on-curve@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/points-on-curve/-/points-on-curve-0.2.0.tgz#7dbb98c43791859434284761330fa893cb81b4d1" + integrity sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A== + +points-on-path@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/points-on-path/-/points-on-path-0.2.1.tgz#553202b5424c53bed37135b318858eacff85dd52" + integrity sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g== + dependencies: + path-data-parser "0.1.0" + points-on-curve "0.2.0" + +postcss-attribute-case-insensitive@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-7.0.1.tgz#0c4500e3bcb2141848e89382c05b5a31c23033a3" + integrity sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw== + dependencies: + postcss-selector-parser "^7.0.0" postcss-calc@^9.0.1: version "9.0.1" @@ -8845,6 +10780,40 @@ postcss-calc@^9.0.1: postcss-selector-parser "^6.0.11" postcss-value-parser "^4.2.0" +postcss-clamp@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-clamp/-/postcss-clamp-4.1.0.tgz#7263e95abadd8c2ba1bd911b0b5a5c9c93e02363" + integrity sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-color-functional-notation@^7.0.10: + version "7.0.10" + resolved "https://registry.yarnpkg.com/postcss-color-functional-notation/-/postcss-color-functional-notation-7.0.10.tgz#f1e9c3e4371889dcdfeabfa8515464fd8338cedc" + integrity sha512-k9qX+aXHBiLTRrWoCJuUFI6F1iF6QJQUXNVWJVSbqZgj57jDhBlOvD8gNUGl35tgqDivbGLhZeW3Ongz4feuKA== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + +postcss-color-hex-alpha@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/postcss-color-hex-alpha/-/postcss-color-hex-alpha-10.0.0.tgz#5dd3eba1f8facb4ea306cba6e3f7712e876b0c76" + integrity sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w== + dependencies: + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + +postcss-color-rebeccapurple@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/postcss-color-rebeccapurple/-/postcss-color-rebeccapurple-10.0.0.tgz#5ada28406ac47e0796dff4056b0a9d5a6ecead98" + integrity sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ== + dependencies: + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + postcss-colormin@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-6.1.0.tgz#076e8d3fb291fbff7b10e6b063be9da42ff6488d" @@ -8863,6 +10832,44 @@ postcss-convert-values@^6.1.0: browserslist "^4.23.0" postcss-value-parser "^4.2.0" +postcss-custom-media@^11.0.6: + version "11.0.6" + resolved "https://registry.yarnpkg.com/postcss-custom-media/-/postcss-custom-media-11.0.6.tgz#6b450e5bfa209efb736830066682e6567bd04967" + integrity sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw== + dependencies: + "@csstools/cascade-layer-name-parser" "^2.0.5" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/media-query-list-parser" "^4.0.3" + +postcss-custom-properties@^14.0.5: + version "14.0.5" + resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-14.0.5.tgz#a180444de695f6e11ee2390be93ff6537663e86c" + integrity sha512-UWf/vhMapZatv+zOuqlfLmYXeOhhHLh8U8HAKGI2VJ00xLRYoAJh4xv8iX6FB6+TLXeDnm0DBLMi00E0hodbQw== + dependencies: + "@csstools/cascade-layer-name-parser" "^2.0.5" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + +postcss-custom-selectors@^8.0.5: + version "8.0.5" + resolved "https://registry.yarnpkg.com/postcss-custom-selectors/-/postcss-custom-selectors-8.0.5.tgz#9448ed37a12271d7ab6cb364b6f76a46a4a323e8" + integrity sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg== + dependencies: + "@csstools/cascade-layer-name-parser" "^2.0.5" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + postcss-selector-parser "^7.0.0" + +postcss-dir-pseudo-class@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/postcss-dir-pseudo-class/-/postcss-dir-pseudo-class-9.0.1.tgz#80d9e842c9ae9d29f6bf5fd3cf9972891d6cc0ca" + integrity sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA== + dependencies: + postcss-selector-parser "^7.0.0" + postcss-discard-comments@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-6.0.2.tgz#e768dcfdc33e0216380623652b0a4f69f4678b6c" @@ -8890,6 +10897,47 @@ postcss-discard-unused@^6.0.5: dependencies: postcss-selector-parser "^6.0.16" +postcss-double-position-gradients@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/postcss-double-position-gradients/-/postcss-double-position-gradients-6.0.2.tgz#185f8eab2db9cf4e34be69b5706c905895bb52ae" + integrity sha512-7qTqnL7nfLRyJK/AHSVrrXOuvDDzettC+wGoienURV8v2svNbu6zJC52ruZtHaO6mfcagFmuTGFdzRsJKB3k5Q== + dependencies: + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + +postcss-focus-visible@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/postcss-focus-visible/-/postcss-focus-visible-10.0.1.tgz#1f7904904368a2d1180b220595d77b6f8a957868" + integrity sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA== + dependencies: + postcss-selector-parser "^7.0.0" + +postcss-focus-within@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/postcss-focus-within/-/postcss-focus-within-9.0.1.tgz#ac01ce80d3f2e8b2b3eac4ff84f8e15cd0057bc7" + integrity sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw== + dependencies: + postcss-selector-parser "^7.0.0" + +postcss-font-variant@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz#efd59b4b7ea8bb06127f2d031bfbb7f24d32fa66" + integrity sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA== + +postcss-gap-properties@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-gap-properties/-/postcss-gap-properties-6.0.0.tgz#d5ff0bdf923c06686499ed2b12e125fe64054fed" + integrity sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw== + +postcss-image-set-function@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/postcss-image-set-function/-/postcss-image-set-function-7.0.0.tgz#538e94e16716be47f9df0573b56bbaca86e1da53" + integrity sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA== + dependencies: + "@csstools/utilities" "^2.0.0" + postcss-value-parser "^4.2.0" + postcss-import@^15.1.0: version "15.1.0" resolved "https://registry.yarnpkg.com/postcss-import/-/postcss-import-15.1.0.tgz#41c64ed8cc0e23735a9698b3249ffdbf704adc70" @@ -8906,6 +10954,17 @@ postcss-js@^4.0.1: dependencies: camelcase-css "^2.0.1" +postcss-lab-function@^7.0.10: + version "7.0.10" + resolved "https://registry.yarnpkg.com/postcss-lab-function/-/postcss-lab-function-7.0.10.tgz#0537bd7245b935fc133298c8896bcbd160540cae" + integrity sha512-tqs6TCEv9tC1Riq6fOzHuHcZyhg4k3gIAMB8GGY/zA1ssGdm6puHMVE7t75aOSoFg7UD2wyrFFhbldiCMyyFTQ== + dependencies: + "@csstools/css-color-parser" "^3.0.10" + "@csstools/css-parser-algorithms" "^3.0.5" + "@csstools/css-tokenizer" "^3.0.4" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/utilities" "^2.0.0" + postcss-load-config@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3" @@ -8923,6 +10982,13 @@ postcss-loader@^7.3.3: jiti "^1.20.0" semver "^7.5.4" +postcss-logical@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/postcss-logical/-/postcss-logical-8.1.0.tgz#4092b16b49e3ecda70c4d8945257da403d167228" + integrity sha512-pL1hXFQ2fEXNKiNiAgtfA005T9FBxky5zkX6s4GZM2D8RkVgRqz3f4g1JUoq925zXv495qk8UNldDwh8uGEDoA== + dependencies: + postcss-value-parser "^4.2.0" + postcss-merge-idents@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-6.0.3.tgz#7b9c31c7bc823c94bec50f297f04e3c2b838ea65" @@ -9016,6 +11082,15 @@ postcss-nested@^6.0.1: dependencies: postcss-selector-parser "^6.0.11" +postcss-nesting@^13.0.1: + version "13.0.1" + resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-13.0.1.tgz#c405796d7245a3e4c267a9956cacfe9670b5d43e" + integrity sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ== + dependencies: + "@csstools/selector-resolve-nested" "^3.0.0" + "@csstools/selector-specificity" "^5.0.0" + postcss-selector-parser "^7.0.0" + postcss-normalize-charset@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-6.0.2.tgz#1ec25c435057a8001dac942942a95ffe66f721e1" @@ -9078,6 +11153,11 @@ postcss-normalize-whitespace@^6.0.2: dependencies: postcss-value-parser "^4.2.0" +postcss-opacity-percentage@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/postcss-opacity-percentage/-/postcss-opacity-percentage-3.0.0.tgz#0b0db5ed5db5670e067044b8030b89c216e1eb0a" + integrity sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ== + postcss-ordered-values@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-6.0.2.tgz#366bb663919707093451ab70c3f99c05672aaae5" @@ -9086,6 +11166,102 @@ postcss-ordered-values@^6.0.2: cssnano-utils "^4.0.2" postcss-value-parser "^4.2.0" +postcss-overflow-shorthand@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/postcss-overflow-shorthand/-/postcss-overflow-shorthand-6.0.0.tgz#f5252b4a2ee16c68cd8a9029edb5370c4a9808af" + integrity sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-page-break@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/postcss-page-break/-/postcss-page-break-3.0.4.tgz#7fbf741c233621622b68d435babfb70dd8c1ee5f" + integrity sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ== + +postcss-place@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/postcss-place/-/postcss-place-10.0.0.tgz#ba36ee4786ca401377ced17a39d9050ed772e5a9" + integrity sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw== + dependencies: + postcss-value-parser "^4.2.0" + +postcss-preset-env@^10.1.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-10.2.0.tgz#ea95a6fc70efb1a26f81e5cf0ccdb321a3439b6e" + integrity sha512-cl13sPBbSqo1Q7Ryb19oT5NZO5IHFolRbIMdgDq4f9w1MHYiL6uZS7uSsjXJ1KzRIcX5BMjEeyxmAevVXENa3Q== + dependencies: + "@csstools/postcss-cascade-layers" "^5.0.1" + "@csstools/postcss-color-function" "^4.0.10" + "@csstools/postcss-color-mix-function" "^3.0.10" + "@csstools/postcss-color-mix-variadic-function-arguments" "^1.0.0" + "@csstools/postcss-content-alt-text" "^2.0.6" + "@csstools/postcss-exponential-functions" "^2.0.9" + "@csstools/postcss-font-format-keywords" "^4.0.0" + "@csstools/postcss-gamut-mapping" "^2.0.10" + "@csstools/postcss-gradients-interpolation-method" "^5.0.10" + "@csstools/postcss-hwb-function" "^4.0.10" + "@csstools/postcss-ic-unit" "^4.0.2" + "@csstools/postcss-initial" "^2.0.1" + "@csstools/postcss-is-pseudo-class" "^5.0.1" + "@csstools/postcss-light-dark-function" "^2.0.9" + "@csstools/postcss-logical-float-and-clear" "^3.0.0" + "@csstools/postcss-logical-overflow" "^2.0.0" + "@csstools/postcss-logical-overscroll-behavior" "^2.0.0" + "@csstools/postcss-logical-resize" "^3.0.0" + "@csstools/postcss-logical-viewport-units" "^3.0.4" + "@csstools/postcss-media-minmax" "^2.0.9" + "@csstools/postcss-media-queries-aspect-ratio-number-values" "^3.0.5" + "@csstools/postcss-nested-calc" "^4.0.0" + "@csstools/postcss-normalize-display-values" "^4.0.0" + "@csstools/postcss-oklab-function" "^4.0.10" + "@csstools/postcss-progressive-custom-properties" "^4.1.0" + "@csstools/postcss-random-function" "^2.0.1" + "@csstools/postcss-relative-color-syntax" "^3.0.10" + "@csstools/postcss-scope-pseudo-class" "^4.0.1" + "@csstools/postcss-sign-functions" "^1.1.4" + "@csstools/postcss-stepped-value-functions" "^4.0.9" + "@csstools/postcss-text-decoration-shorthand" "^4.0.2" + "@csstools/postcss-trigonometric-functions" "^4.0.9" + "@csstools/postcss-unset-value" "^4.0.0" + autoprefixer "^10.4.21" + browserslist "^4.24.5" + css-blank-pseudo "^7.0.1" + css-has-pseudo "^7.0.2" + css-prefers-color-scheme "^10.0.0" + cssdb "^8.3.0" + postcss-attribute-case-insensitive "^7.0.1" + postcss-clamp "^4.1.0" + postcss-color-functional-notation "^7.0.10" + postcss-color-hex-alpha "^10.0.0" + postcss-color-rebeccapurple "^10.0.0" + postcss-custom-media "^11.0.6" + postcss-custom-properties "^14.0.5" + postcss-custom-selectors "^8.0.5" + postcss-dir-pseudo-class "^9.0.1" + postcss-double-position-gradients "^6.0.2" + postcss-focus-visible "^10.0.1" + postcss-focus-within "^9.0.1" + postcss-font-variant "^5.0.0" + postcss-gap-properties "^6.0.0" + postcss-image-set-function "^7.0.0" + postcss-lab-function "^7.0.10" + postcss-logical "^8.1.0" + postcss-nesting "^13.0.1" + postcss-opacity-percentage "^3.0.0" + postcss-overflow-shorthand "^6.0.0" + postcss-page-break "^3.0.4" + postcss-place "^10.0.0" + postcss-pseudo-class-any-link "^10.0.1" + postcss-replace-overflow-wrap "^4.0.0" + postcss-selector-not "^8.0.1" + +postcss-pseudo-class-any-link@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/postcss-pseudo-class-any-link/-/postcss-pseudo-class-any-link-10.0.1.tgz#06455431171bf44b84d79ebaeee9fd1c05946544" + integrity sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q== + dependencies: + postcss-selector-parser "^7.0.0" + postcss-reduce-idents@^6.0.3: version "6.0.3" resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-6.0.3.tgz#b0d9c84316d2a547714ebab523ec7d13704cd486" @@ -9108,6 +11284,18 @@ postcss-reduce-transforms@^6.0.2: dependencies: postcss-value-parser "^4.2.0" +postcss-replace-overflow-wrap@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz#d2df6bed10b477bf9c52fab28c568b4b29ca4319" + integrity sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw== + +postcss-selector-not@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/postcss-selector-not/-/postcss-selector-not-8.0.1.tgz#f2df9c6ac9f95e9fe4416ca41a957eda16130172" + integrity sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA== + dependencies: + postcss-selector-parser "^7.0.0" + postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.16, postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4: version "6.1.0" resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz#49694cb4e7c649299fea510a29fa6577104bcf53" @@ -9116,6 +11304,14 @@ postcss-selector-parser@^6.0.11, postcss-selector-parser@^6.0.16, postcss-select cssesc "^3.0.0" util-deprecate "^1.0.2" +postcss-selector-parser@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz#4d6af97eba65d73bc4d84bcb343e865d7dd16262" + integrity sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA== + dependencies: + cssesc "^3.0.0" + util-deprecate "^1.0.2" + postcss-sort-media-queries@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/postcss-sort-media-queries/-/postcss-sort-media-queries-5.2.0.tgz#4556b3f982ef27d3bac526b99b6c0d3359a6cf97" @@ -9246,7 +11442,7 @@ pretty-time@^1.1.0: resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e" integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA== -prism-react-renderer@^2.0.6, prism-react-renderer@^2.1.0, prism-react-renderer@^2.3.0: +prism-react-renderer@^2.0.6, prism-react-renderer@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.3.1.tgz#e59e5450052ede17488f6bc85de1553f584ff8d5" integrity sha512-Rdf+HzBLR7KYjzpJ1rSoxT9ioO85nZngQEoFIhL07XhtJHlCU3SOz0GJ6+qvMyQe0Se+BV3qpe6Yd/NmQF5Juw== @@ -9314,18 +11510,6 @@ proxy-from-env@1.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== -public-encrypt@^4.0.0: - version "4.0.3" - resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0" - integrity sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q== - dependencies: - bn.js "^4.1.0" - browserify-rsa "^4.0.0" - create-hash "^1.1.0" - parse-asn1 "^5.0.0" - randombytes "^2.0.1" - safe-buffer "^5.1.2" - pump@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" @@ -9334,7 +11518,7 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -punycode@^1.3.2, punycode@^1.4.1: +punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== @@ -9384,50 +11568,35 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" -qs@^6.11.2: - version "6.12.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.12.1.tgz#39422111ca7cbdb70425541cba20c7d7b216599a" - integrity sha512-zWmv4RSuB9r2mYQw3zxQuHWeU+42aKi1wWig/j4ele4ygELZ7PEO6MM7rim9oAQH2A5MWfsAVf/jPvTPgCbvUQ== +qs@^6.12.3: + version "6.14.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.0.tgz#c63fa40680d2c5c941412a0e899c89af60c0a930" + integrity sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w== dependencies: - side-channel "^1.0.6" + side-channel "^1.1.0" -querystring-es3@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/querystring-es3/-/querystring-es3-0.2.1.tgz#9ec61f79049875707d69414596fd907a4d711e73" - integrity sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA== +quansync@^0.2.8: + version "0.2.10" + resolved "https://registry.yarnpkg.com/quansync/-/quansync-0.2.10.tgz#32053cf166fa36511aae95fc49796116f2dc20e1" + integrity sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A== queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -queue@6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" - integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== - dependencies: - inherits "~2.0.3" - quick-lru@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== -randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0: +randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== dependencies: safe-buffer "^5.1.0" -randomfill@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/randomfill/-/randomfill-1.0.4.tgz#c92196fc86ab42be983f1bf31778224931d61458" - integrity sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw== - dependencies: - randombytes "^2.0.5" - safe-buffer "^5.1.0" - range-parser@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" @@ -9474,36 +11643,6 @@ react-copy-to-clipboard@^5.1.0: copy-to-clipboard "^3.3.1" prop-types "^15.8.1" -react-dev-utils@^12.0.1: - version "12.0.1" - resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73" - integrity sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ== - dependencies: - "@babel/code-frame" "^7.16.0" - address "^1.1.2" - browserslist "^4.18.1" - chalk "^4.1.2" - cross-spawn "^7.0.3" - detect-port-alt "^1.1.6" - escape-string-regexp "^4.0.0" - filesize "^8.0.6" - find-up "^5.0.0" - fork-ts-checker-webpack-plugin "^6.5.0" - global-modules "^2.0.0" - globby "^11.0.4" - gzip-size "^6.0.0" - immer "^9.0.7" - is-root "^2.1.0" - loader-utils "^3.2.0" - open "^8.4.0" - pkg-up "^3.1.0" - prompts "^2.4.2" - react-error-overlay "^6.0.11" - recursive-readdir "^2.2.2" - shell-quote "^1.7.3" - strip-ansi "^6.0.1" - text-table "^0.2.0" - react-dom@^18.2.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" @@ -9512,12 +11651,7 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" -react-error-overlay@^6.0.11: - version "6.0.11" - resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" - integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== - -react-fast-compare@^3.0.1, react-fast-compare@^3.2.0, react-fast-compare@^3.2.2: +react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== @@ -9527,19 +11661,10 @@ react-google-charts@^5.2.1: resolved "https://registry.yarnpkg.com/react-google-charts/-/react-google-charts-5.2.1.tgz#d9cbe8ed45d7c0fafefea5c7c3361bee76648454" integrity sha512-mCbPiObP8yWM5A9ogej7Qp3/HX4EzOwuEzUYvcfHtL98Xt4V/brD14KgfDzSNNtyD48MNXCpq5oVaYKt0ykQUQ== -react-helmet-async@*: - version "2.0.5" - resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-2.0.5.tgz#cfc70cd7bb32df7883a8ed55502a1513747223ec" - integrity sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg== - dependencies: - invariant "^2.2.4" - react-fast-compare "^3.2.2" - shallowequal "^1.1.0" - -react-helmet-async@^1.3.0: +react-helmet-async@^1.3.0, "react-helmet-async@npm:@slorber/react-helmet-async@1.3.0": version "1.3.0" - resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.3.0.tgz#7bd5bf8c5c69ea9f02f6083f14ce33ef545c222e" - integrity sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg== + resolved "https://registry.yarnpkg.com/@slorber/react-helmet-async/-/react-helmet-async-1.3.0.tgz#11fbc6094605cf60aa04a28c17e0aab894b4ecff" + integrity sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A== dependencies: "@babel/runtime" "^7.12.5" invariant "^2.2.4" @@ -9567,10 +11692,10 @@ react-is@^18.0.0: resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== -react-json-view-lite@^1.2.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/react-json-view-lite/-/react-json-view-lite-1.4.0.tgz#0ff493245f4550abe5e1f1836f170fa70bb95914" - integrity sha512-wh6F6uJyYAmQ4fK0e8dSQMEWuvTs2Wr3el3sLD9bambX1+pSWUVXIz1RFaoy3TI1mZ0FqdpKq9YgbgTTgyrmXA== +react-json-view-lite@^2.3.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/react-json-view-lite/-/react-json-view-lite-2.4.1.tgz#0d06696a06aaf4a74e890302b76cf8cddcc45d60" + integrity sha512-fwFYknRIBxjbFm0kBDrzgBy1xa5tDg2LyXXBepC5f1b+MY3BUClMCsvanMPn089JbV1Eg3nZcrp0VCuH43aXnA== react-lifecycles-compat@^3.0.0: version "3.0.4" @@ -9708,7 +11833,7 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" -readable-stream@^2.0.1, readable-stream@^2.3.8: +readable-stream@^2.0.1: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -9721,7 +11846,7 @@ readable-stream@^2.0.1, readable-stream@^2.3.8: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable-stream@^3.5.0, readable-stream@^3.6.0: +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -9730,16 +11855,10 @@ readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.4.0, readable string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^4.0.0: - version "4.5.2" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.5.2.tgz#9e7fc4c45099baeed934bff6eb97ba6cf2729e09" - integrity sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g== - dependencies: - abort-controller "^3.0.0" - buffer "^6.0.3" - events "^3.3.0" - process "^0.11.10" - string_decoder "^1.3.0" +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== readdirp@~3.6.0: version "3.6.0" @@ -9748,11 +11867,6 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -reading-time@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/reading-time/-/reading-time-1.5.0.tgz#d2a7f1b6057cb2e169beaf87113cc3411b5bc5bb" - integrity sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg== - rechoir@^0.6.2: version "0.6.2" resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" @@ -9760,13 +11874,6 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" -recursive-readdir@^2.2.2: - version "2.2.3" - resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.3.tgz#e726f328c0d69153bcabd5c322d3195252379372" - integrity sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA== - dependencies: - minimatch "^3.0.5" - redux-thunk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.4.2.tgz#b9d05d11994b99f7a91ea223e8b04cf0afa5ef3b" @@ -9791,6 +11898,13 @@ regenerate-unicode-properties@^10.1.0: dependencies: regenerate "^1.4.2" +regenerate-unicode-properties@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz#626e39df8c372338ea9b8028d1f99dc3fd9c3db0" + integrity sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA== + dependencies: + regenerate "^1.4.2" + regenerate@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" @@ -9820,6 +11934,18 @@ regexpu-core@^5.3.1: unicode-match-property-ecmascript "^2.0.0" unicode-match-property-value-ecmascript "^2.1.0" +regexpu-core@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.2.0.tgz#0e5190d79e542bf294955dccabae04d3c7d53826" + integrity sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA== + dependencies: + regenerate "^1.4.2" + regenerate-unicode-properties "^10.2.0" + regjsgen "^0.8.0" + regjsparser "^0.12.0" + unicode-match-property-ecmascript "^2.0.0" + unicode-match-property-value-ecmascript "^2.1.0" + registry-auth-token@^5.0.1: version "5.0.2" resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-5.0.2.tgz#8b026cc507c8552ebbe06724136267e63302f756" @@ -9834,6 +11960,18 @@ registry-url@^6.0.0: dependencies: rc "1.2.8" +regjsgen@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.8.0.tgz#df23ff26e0c5b300a6470cad160a9d090c3a37ab" + integrity sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q== + +regjsparser@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.12.0.tgz#0e846df6c6530586429377de56e0475583b088dc" + integrity sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ== + dependencies: + jsesc "~3.0.2" + regjsparser@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.9.1.tgz#272d05aa10c7c1f67095b1ff0addae8442fc5709" @@ -9895,6 +12033,16 @@ remark-frontmatter@^5.0.0: micromark-extension-frontmatter "^2.0.0" unified "^11.0.0" +remark-gfm@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-3.0.1.tgz#0b180f095e3036545e9dddac0e8df3fa5cfee54f" + integrity sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig== + dependencies: + "@types/mdast" "^3.0.0" + mdast-util-gfm "^2.0.0" + micromark-extension-gfm "^2.0.0" + unified "^10.0.0" + remark-gfm@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.0.tgz#aea777f0744701aa288b67d28c43565c7e8c35de" @@ -9975,6 +12123,11 @@ renderkid@^3.0.0: lodash "^4.17.21" strip-ansi "^6.0.1" +repeat-string@^1.0.0: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -10055,23 +12208,20 @@ rimraf@3.0.2, rimraf@^3.0.2: dependencies: glob "^7.1.3" -ripemd160@^2.0.0, ripemd160@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" - integrity sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA== - dependencies: - hash-base "^3.0.0" - inherits "^2.0.1" - robust-predicates@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== -rtl-detect@^1.0.4: - version "1.1.2" - resolved "https://registry.yarnpkg.com/rtl-detect/-/rtl-detect-1.1.2.tgz#ca7f0330af5c6bb626c15675c642ba85ad6273c6" - integrity sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ== +roughjs@^4.6.6: + version "4.6.6" + resolved "https://registry.yarnpkg.com/roughjs/-/roughjs-4.6.6.tgz#1059f49a5e0c80dee541a005b20cc322b222158b" + integrity sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ== + dependencies: + hachure-fill "^0.5.2" + path-data-parser "^0.1.0" + points-on-curve "^0.2.0" + points-on-path "^0.2.1" rtlcss@^4.1.0: version "4.1.1" @@ -10095,13 +12245,6 @@ rw@1: resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== -rxjs@^7.5.4: - version "7.8.1" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" - integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== - dependencies: - tslib "^2.1.0" - sade@^1.7.3: version "1.8.1" resolved "https://registry.yarnpkg.com/sade/-/sade-1.8.1.tgz#0a78e81d658d394887be57d2a409bf703a3b2701" @@ -10114,7 +12257,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -10124,32 +12267,23 @@ safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sass-loader@^10.1.1: - version "10.5.2" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-10.5.2.tgz#1ca30534fff296417b853c7597ca3b0bbe8c37d0" - integrity sha512-vMUoSNOUKJILHpcNCCyD23X34gve1TS7Rjd9uXHeKqhvBG39x6XbswFDtpbTElj6XdMFezoWhkh5vtKudf2cgQ== - dependencies: - klona "^2.0.4" - loader-utils "^2.0.0" - neo-async "^2.6.2" - schema-utils "^3.0.0" - semver "^7.3.2" - -sass-loader@^13.3.2: - version "13.3.3" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-13.3.3.tgz#60df5e858788cffb1a3215e5b92e9cba61e7e133" - integrity sha512-mt5YN2F1MOZr3d/wBRcZxeFgwgkH44wVc2zohO2YF6JiOMkiXe4BYRZpSu2sO1g71mo/j16txzUhsKZlqjVGzA== +sass-loader@^16.0.2: + version "16.0.5" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-16.0.5.tgz#257bc90119ade066851cafe7f2c3f3504c7cda98" + integrity sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw== dependencies: neo-async "^2.6.2" -sass@^1.58.1: - version "1.77.5" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.5.tgz#5f9009820297521356e962c0bed13ee36710edfe" - integrity sha512-oDfX1mukIlxacPdQqNb6mV2tVCrnE+P3nVYioy72V5tlk56CPNcO4TCuFcaCRKKfJ1M3lH95CleRS+dVKL2qMg== +sass@^1.80.4: + version "1.89.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.0.tgz#6df72360c5c3ec2a9833c49adafe57b28206752d" + integrity sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ== dependencies: - chokidar ">=3.0.0 <4.0.0" - immutable "^4.0.0" + chokidar "^4.0.0" + immutable "^5.0.2" source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" sax@^1.2.4: version "1.4.1" @@ -10163,14 +12297,10 @@ scheduler@^0.23.2: dependencies: loose-envify "^1.1.0" -schema-utils@2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" - integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== - dependencies: - "@types/json-schema" "^7.0.4" - ajv "^6.12.2" - ajv-keywords "^3.4.1" +schema-dts@^1.1.2: + version "1.1.5" + resolved "https://registry.yarnpkg.com/schema-dts/-/schema-dts-1.1.5.tgz#9237725d305bac3469f02b292a035107595dc324" + integrity sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg== schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.2.0: version "3.3.0" @@ -10191,6 +12321,16 @@ schema-utils@^4.0.0, schema-utils@^4.0.1: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" +schema-utils@^4.3.0, schema-utils@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.2.tgz#0c10878bf4a73fd2b1dfd14b9462b26788c806ae" + integrity sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ== + dependencies: + "@types/json-schema" "^7.0.9" + ajv "^8.9.0" + ajv-formats "^2.1.1" + ajv-keywords "^5.1.0" + section-matter@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/section-matter/-/section-matter-1.0.0.tgz#e9041953506780ec01d59f292a19c7b850b84167" @@ -10238,7 +12378,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.2, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.4: +semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.4: version "7.6.2" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== @@ -10262,25 +12402,24 @@ send@0.18.0: range-parser "~1.2.1" statuses "2.0.1" -serialize-javascript@^6.0.0, serialize-javascript@^6.0.1: +serialize-javascript@^6.0.0, serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== dependencies: randombytes "^2.1.0" -serve-handler@^6.1.5: - version "6.1.5" - resolved "https://registry.yarnpkg.com/serve-handler/-/serve-handler-6.1.5.tgz#a4a0964f5c55c7e37a02a633232b6f0d6f068375" - integrity sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg== +serve-handler@^6.1.6: + version "6.1.6" + resolved "https://registry.yarnpkg.com/serve-handler/-/serve-handler-6.1.6.tgz#50803c1d3e947cd4a341d617f8209b22bd76cfa1" + integrity sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ== dependencies: bytes "3.0.0" content-disposition "0.5.2" - fast-url-parser "1.1.3" mime-types "2.1.18" minimatch "3.1.2" path-is-inside "1.0.2" - path-to-regexp "2.2.1" + path-to-regexp "3.3.0" range-parser "1.2.0" serve-index@^1.9.1: @@ -10318,11 +12457,6 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" -setimmediate@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== - setprototypeof@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" @@ -10333,14 +12467,6 @@ setprototypeof@1.2.0: resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== -sha.js@^2.4.0, sha.js@^2.4.8: - version "2.4.11" - resolved "https://registry.yarnpkg.com/sha.js/-/sha.js-2.4.11.tgz#37a5cf0b81ecbc6943de109ba2960d1b26584ae7" - integrity sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ== - dependencies: - inherits "^2.0.1" - safe-buffer "^5.0.1" - shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -10365,12 +12491,12 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shell-quote@^1.7.3, shell-quote@^1.8.1: +shell-quote@^1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.1.tgz#6dbf4db75515ad5bac63b4f1894c3a154c766680" integrity sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA== -shelljs@0.8.5, shelljs@^0.8.5: +shelljs@0.8.5: version "0.8.5" resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== @@ -10423,7 +12549,36 @@ should@^13.2.1: should-type-adaptors "^1.0.1" should-util "^1.0.0" -side-channel@^1.0.4, side-channel@^1.0.6: +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + +side-channel@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== @@ -10433,6 +12588,17 @@ side-channel@^1.0.4, side-channel@^1.0.6: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -10457,16 +12623,6 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== -sitemap@7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/sitemap/-/sitemap-7.1.1.tgz#eeed9ad6d95499161a3eadc60f8c6dce4bea2bef" - integrity sha512-mK3aFtjz4VdJN0igpIJrinf3EO8U8mxOPsTBzSsy06UtjZQJ3YY3o3Xa7zSc5nMqcMrRwlChHZ18Kxg0caiPBg== - dependencies: - "@types/node" "^17.0.5" - "@types/sax" "^1.2.1" - arg "^5.0.0" - sax "^1.2.4" - sitemap@^7.1.1: version "7.1.2" resolved "https://registry.yarnpkg.com/sitemap/-/sitemap-7.1.2.tgz#6ce1deb43f6f177c68bc59cf93632f54e3ae6b72" @@ -10592,28 +12748,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -std-env@^3.0.1: - version "3.7.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.7.0.tgz#c9f7386ced6ecf13360b6c6c55b8aaa4ef7481d2" - integrity sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg== - -stream-browserify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" - integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== - dependencies: - inherits "~2.0.4" - readable-stream "^3.5.0" - -stream-http@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/stream-http/-/stream-http-3.2.0.tgz#1872dfcf24cb15752677e40e5c3f9cc1926028b5" - integrity sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A== - dependencies: - builtin-status-codes "^3.0.0" - inherits "^2.0.4" - readable-stream "^3.6.0" - xtend "^4.0.2" +std-env@^3.7.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" + integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" @@ -10642,7 +12780,7 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string_decoder@^1.1.1, string_decoder@^1.3.0: +string_decoder@^1.1.1: version "1.3.0" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== @@ -10736,10 +12874,10 @@ stylehacks@^6.1.1: browserslist "^4.23.0" postcss-selector-parser "^6.0.16" -stylis@^4.1.3: - version "4.3.2" - resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.2.tgz#8f76b70777dd53eb669c6f58c997bf0a9972e444" - integrity sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg== +stylis@^4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.6.tgz#7c7b97191cb4f195f03ecab7d52f7902ed378320" + integrity sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ== sucrase@^3.31.0, sucrase@^3.32.0: version "3.35.0" @@ -10815,7 +12953,7 @@ swagger2openapi@7.0.8, swagger2openapi@^7.0.8: yaml "^1.10.0" yargs "^17.0.1" -swc-loader@^0.2.3: +swc-loader@^0.2.6: version "0.2.6" resolved "https://registry.yarnpkg.com/swc-loader/-/swc-loader-0.2.6.tgz#bf0cba8eeff34bb19620ead81d1277fefaec6bc8" integrity sha512-9Zi9UP2YmDpgmQVbyOPJClY0dwf58JDyDMQ7uRc4krmc72twNI2fvlBWHLqVekBpPc7h5NJkGVT1zNDxFrqhvg== @@ -10850,11 +12988,6 @@ tailwindcss@^3.2.4: resolve "^1.22.2" sucrase "^3.32.0" -tapable@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" - integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== - tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" @@ -10892,6 +13025,17 @@ terser-webpack-plugin@^5.3.10, terser-webpack-plugin@^5.3.9: serialize-javascript "^6.0.1" terser "^5.26.0" +terser-webpack-plugin@^5.3.11: + version "5.3.14" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz#9031d48e57ab27567f02ace85c7d690db66c3e06" + integrity sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.25" + jest-worker "^27.4.5" + schema-utils "^4.3.0" + serialize-javascript "^6.0.2" + terser "^5.31.1" + terser@^5.10.0, terser@^5.15.1, terser@^5.26.0: version "5.31.1" resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.1.tgz#735de3c987dd671e95190e6b98cfe2f07f3cf0d4" @@ -10902,10 +13046,15 @@ terser@^5.10.0, terser@^5.15.1, terser@^5.26.0: commander "^2.20.0" source-map-support "~0.5.20" -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +terser@^5.31.1: + version "5.40.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.40.0.tgz#839a80db42bfee8340085f44ea99b5cba36c55c8" + integrity sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.14.0" + commander "^2.20.0" + source-map-support "~0.5.20" thenify-all@^1.0.0: version "1.6.0" @@ -10931,13 +13080,6 @@ thunky@^1.0.2: resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== -timers-browserify@^2.0.12: - version "2.0.12" - resolved "https://registry.yarnpkg.com/timers-browserify/-/timers-browserify-2.0.12.tgz#44a45c11fbf407f34f97bccd1577c652361b00ee" - integrity sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ== - dependencies: - setimmediate "^1.0.4" - tiny-invariant@^1.0.2: version "1.3.3" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" @@ -10948,6 +13090,16 @@ tiny-warning@^1.0.0: resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== +tinyexec@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.1.tgz#70c31ab7abbb4aea0a24f55d120e5990bfa1e0b1" + integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw== + +tinypool@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" + integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== + to-fast-properties@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" @@ -11005,22 +13157,22 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -tslib@^2.0.3, tslib@^2.1.0, tslib@^2.6.0: +tslib@^2.0.3, tslib@^2.6.0: version "2.6.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== -tty-browserify@^0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.1.tgz#3f05251ee17904dfd0677546670db9651682b811" - integrity sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw== +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== type-fest@^1.0.1: version "1.4.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== -type-fest@^2.13.0, type-fest@^2.14.0, type-fest@^2.5.0: +type-fest@^2.13.0, type-fest@^2.5.0: version "2.19.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== @@ -11040,6 +13192,11 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" +ufo@^1.5.4: + version "1.6.1" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b" + integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA== + unbzip2-stream@1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz#b0da04c4371311df771cdc215e87f2130991ace7" @@ -11191,7 +13348,7 @@ unist-util-stringify-position@^4.0.0: dependencies: "@types/unist" "^3.0.0" -unist-util-visit-parents@^5.1.1: +unist-util-visit-parents@^5.0.0, unist-util-visit-parents@^5.1.1: version "5.1.3" resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz#b4520811b0ca34285633785045df7a8d6776cfeb" integrity sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg== @@ -11243,6 +13400,14 @@ update-browserslist-db@^1.0.16: escalade "^3.1.2" picocolors "^1.0.1" +update-browserslist-db@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + update-notifier@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-6.0.2.tgz#a6990253dfe6d5a02bd04fbb6a61543f55026b60" @@ -11279,13 +13444,13 @@ url-loader@^4.1.1: mime-types "^2.1.27" schema-utils "^3.0.0" -url@^0.11.0: - version "0.11.3" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.3.tgz#6f495f4b935de40ce4a0a52faee8954244f3d3ad" - integrity sha512-6hxOLGfZASQK/cijlZnZJTq8OXAkt/3YGfQX45vvMYXpZoo8NdWZcY73K108Jf759lS1Bv/8wXnHDTSz17dSRw== +url@^0.11.1: + version "0.11.4" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.4.tgz#adca77b3562d56b72746e76b330b7f27b6721f3c" + integrity sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg== dependencies: punycode "^1.4.1" - qs "^6.11.2" + qs "^6.12.3" use-editable@^2.3.3: version "2.3.3" @@ -11304,17 +13469,6 @@ util@^0.10.3: dependencies: inherits "2.0.3" -util@^0.12.4, util@^0.12.5: - version "0.12.5" - resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" - integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== - dependencies: - inherits "^2.0.3" - is-arguments "^1.0.4" - is-generator-function "^1.0.7" - is-typed-array "^1.1.3" - which-typed-array "^1.1.2" - utila@~0.4: version "0.4.0" resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" @@ -11335,10 +13489,10 @@ uuid@8.3.2, uuid@^8.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +uuid@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== uvu@^0.5.0: version "0.5.6" @@ -11449,21 +13603,40 @@ vfile@^6.0.0, vfile@^6.0.1: unist-util-stringify-position "^4.0.0" vfile-message "^4.0.0" -vm-browserify@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" - integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +vscode-jsonrpc@8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz#f43dfa35fb51e763d17cd94dcca0c9458f35abf9" + integrity sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA== -wait-on@6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-6.0.1.tgz#16bbc4d1e4ebdd41c5b4e63a2e16dbd1f4e5601e" - integrity sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw== +vscode-languageserver-protocol@3.17.5: + version "3.17.5" + resolved "https://registry.yarnpkg.com/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz#864a8b8f390835572f4e13bd9f8313d0e3ac4bea" + integrity sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg== dependencies: - axios "^0.25.0" - joi "^17.6.0" - lodash "^4.17.21" - minimist "^1.2.5" - rxjs "^7.5.4" + vscode-jsonrpc "8.2.0" + vscode-languageserver-types "3.17.5" + +vscode-languageserver-textdocument@~1.0.11: + version "1.0.12" + resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz#457ee04271ab38998a093c68c2342f53f6e4a631" + integrity sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA== + +vscode-languageserver-types@3.17.5: + version "3.17.5" + resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz#3273676f0cf2eab40b3f44d085acbb7f08a39d8a" + integrity sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg== + +vscode-languageserver@~9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz#500aef82097eb94df90d008678b0b6b5f474015b" + integrity sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g== + dependencies: + vscode-languageserver-protocol "3.17.5" + +vscode-uri@~3.0.8: + version "3.0.8" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" + integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== warning@^4.0.3: version "4.0.3" @@ -11492,17 +13665,12 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== -web-worker@^1.2.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.3.0.tgz#e5f2df5c7fe356755a5fb8f8410d4312627e6776" - integrity sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA== - webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== -webpack-bundle-analyzer@^4.9.0: +webpack-bundle-analyzer@^4.10.2: version "4.10.2" resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz#633af2862c213730be3dbdf40456db171b60d5bd" integrity sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw== @@ -11531,7 +13699,7 @@ webpack-dev-middleware@^5.3.4: range-parser "^1.2.1" schema-utils "^4.0.0" -webpack-dev-server@^4.15.1: +webpack-dev-server@^4.15.2: version "4.15.2" resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.15.2.tgz#9e0c70a42a012560860adb186986da1248333173" integrity sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g== @@ -11576,12 +13744,21 @@ webpack-merge@^5.9.0: flat "^5.0.2" wildcard "^2.0.0" +webpack-merge@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-6.0.1.tgz#50c776868e080574725abc5869bd6e4ef0a16c6a" + integrity sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg== + dependencies: + clone-deep "^4.0.1" + flat "^5.0.2" + wildcard "^2.0.1" + webpack-sources@^3.2.3: version "3.2.3" resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@^5.61.0, webpack@^5.88.1: +webpack@^5.88.1: version "5.92.0" resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.92.0.tgz#cc114c71e6851d220b1feaae90159ed52c876bdf" integrity sha512-Bsw2X39MYIgxouNATyVpCNVWBCuUwDgWtN78g6lSdPJRLaQ/PUVm/oXcaRAyY/sMFoKFQrsPeqvTizWtq7QPCA== @@ -11611,15 +13788,49 @@ webpack@^5.61.0, webpack@^5.88.1: watchpack "^2.4.1" webpack-sources "^3.2.3" -webpackbar@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/webpackbar/-/webpackbar-5.0.2.tgz#d3dd466211c73852741dfc842b7556dcbc2b0570" - integrity sha512-BmFJo7veBDgQzfWXl/wwYXr/VFus0614qZ8i9znqcl9fnEdiVkdbi0TedLQ6xAK92HZHDJ0QmyQ0fmuZPAgCYQ== +webpack@^5.95.0: + version "5.99.9" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.9.tgz#d7de799ec17d0cce3c83b70744b4aedb537d8247" + integrity sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg== dependencies: - chalk "^4.1.0" - consola "^2.15.3" + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.14.0" + browserslist "^4.24.0" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.17.1" + es-module-lexer "^1.2.1" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.11" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^4.3.2" + tapable "^2.1.1" + terser-webpack-plugin "^5.3.11" + watchpack "^2.4.1" + webpack-sources "^3.2.3" + +webpackbar@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/webpackbar/-/webpackbar-6.0.1.tgz#5ef57d3bf7ced8b19025477bc7496ea9d502076b" + integrity sha512-TnErZpmuKdwWBdMoexjio3KKX6ZtoKHRVvLIU0A47R0VVBDtx3ZyOJDktgYixhoJokZTYTt1Z37OkO9pnGJa9Q== + dependencies: + ansi-escapes "^4.3.2" + chalk "^4.1.2" + consola "^3.2.3" + figures "^3.2.0" + markdown-table "^2.0.0" pretty-time "^1.1.0" - std-env "^3.0.1" + std-env "^3.7.0" + wrap-ansi "^7.0.0" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: version "0.7.4" @@ -11643,24 +13854,6 @@ whatwg-url@^5.0.0: tr46 "~0.0.3" webidl-conversions "^3.0.0" -which-typed-array@^1.1.14, which-typed-array@^1.1.2: - version "1.1.15" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" - integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.2" - -which@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -11675,7 +13868,7 @@ widest-line@^4.0.1: dependencies: string-width "^5.0.1" -wildcard@^2.0.0: +wildcard@^2.0.0, wildcard@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== @@ -11761,11 +13954,6 @@ xml-parser-xo@^3.2.0: resolved "https://registry.yarnpkg.com/xml-parser-xo/-/xml-parser-xo-3.2.0.tgz#c633ab55cf1976d6b03ab4a6a85045093ac32b73" integrity sha512-8LRU6cq+d7mVsoDaMhnkkt3CTtAs4153p49fRo+HIB3I1FD1o5CeXRjRH29sQevIfVJIcPjKSsPU/+Ujhq09Rg== -xtend@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" @@ -11786,7 +13974,7 @@ yaml-ast-parser@0.0.43: resolved "https://registry.yarnpkg.com/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz#e8a23e6fb4c38076ab92995c5dca33f3d3d7c9bb" integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A== -yaml@1.10.2, yaml@^1.10.0, yaml@^1.7.2: +yaml@1.10.2, yaml@^1.10.0: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== @@ -11822,11 +14010,6 @@ yauzl@^2.10.0: buffer-crc32 "~0.2.3" fd-slicer "~1.1.0" -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - yocto-queue@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" From cdf1860083e02906d0399d5c210135ccb1fda242 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Mon, 2 Jun 2025 00:42:11 -0700 Subject: [PATCH 074/123] chore: remove unparsed md characters (#9983) This pull request includes a minor change to the `README.md` file. It removes a broken markdown link syntax for an image and replaces it with the correct image syntax to properly display the "New Login Showcase" image. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 285e50964c..3d33e20e57 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,7 @@ Use [Console](https://zitadel.com/docs/guides/manage/console/overview) or our [A ### Login V2 Check out our new Login V2 version in our [typescript repository](https://github.com/zitadel/typescript) or in our [documentation](https://zitadel.com/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta) -[![New Login Showcase](https://github.com/user-attachments/assets/cb5c5212-128b-4dc9-b11d-cabfd3f73e26)] +![New Login Showcase](https://github.com/user-attachments/assets/cb5c5212-128b-4dc9-b11d-cabfd3f73e26) ## Security From b660d6ab9a2cd26c579693264d5d20a39ecc6c7d Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:16:13 +0200 Subject: [PATCH 075/123] fix(queue): reset projection list before each `Register` call (#10001) # Which Problems Are Solved if Zitadel was started using `start-from-init` or `start-from-setup` there were rare cases where a panic occured when `Notifications.LegacyEnabled` was set to false. The cause was a list which was not reset before refilling. # How the Problems Are Solved The list is now reset before each time it gets filled. # Additional Changes Ensure all contexts are canceled for the init and setup functions for `start-from-init- or `start-from-setup` commands. # Additional Context none --- cmd/start/start_from_init.go | 11 +++++++++-- cmd/start/start_from_setup.go | 7 ++++++- internal/notification/projections.go | 3 +++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/cmd/start/start_from_init.go b/cmd/start/start_from_init.go index 62d705b33c..41972e16ad 100644 --- a/cmd/start/start_from_init.go +++ b/cmd/start/start_from_init.go @@ -1,6 +1,8 @@ package start import ( + "context" + "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zitadel/logging" @@ -29,14 +31,19 @@ Requirements: masterKey, err := key.MasterKey(cmd) logging.OnError(err).Panic("No master key provided") - initialise.InitAll(cmd.Context(), initialise.MustNewConfig(viper.GetViper())) + initCtx, cancel := context.WithCancel(cmd.Context()) + initialise.InitAll(initCtx, initialise.MustNewConfig(viper.GetViper())) + cancel() err = setup.BindInitProjections(cmd) logging.OnError(err).Fatal("unable to bind \"init-projections\" flag") setupConfig := setup.MustNewConfig(viper.GetViper()) setupSteps := setup.MustNewSteps(viper.New()) - setup.Setup(cmd.Context(), setupConfig, setupSteps, masterKey) + + setupCtx, cancel := context.WithCancel(cmd.Context()) + setup.Setup(setupCtx, setupConfig, setupSteps, masterKey) + cancel() startConfig := MustNewConfig(viper.GetViper()) diff --git a/cmd/start/start_from_setup.go b/cmd/start/start_from_setup.go index a8b7295f2a..3e8a13705e 100644 --- a/cmd/start/start_from_setup.go +++ b/cmd/start/start_from_setup.go @@ -1,6 +1,8 @@ package start import ( + "context" + "github.com/spf13/cobra" "github.com/spf13/viper" "github.com/zitadel/logging" @@ -34,7 +36,10 @@ Requirements: setupConfig := setup.MustNewConfig(viper.GetViper()) setupSteps := setup.MustNewSteps(viper.New()) - setup.Setup(cmd.Context(), setupConfig, setupSteps, masterKey) + + setupCtx, cancel := context.WithCancel(cmd.Context()) + setup.Setup(setupCtx, setupConfig, setupSteps, masterKey) + cancel() startConfig := MustNewConfig(viper.GetViper()) diff --git a/internal/notification/projections.go b/internal/notification/projections.go index 7fda08135c..7fedaaf301 100644 --- a/internal/notification/projections.go +++ b/internal/notification/projections.go @@ -43,6 +43,9 @@ func Register( queue.ShouldStart() } + // make sure the slice does not contain old values + projections = nil + q := handlers.NewNotificationQueries(queries, es, externalDomain, externalPort, externalSecure, fileSystemPath, userEncryption, smtpEncryption, smsEncryption) c := newChannels(q) projections = append(projections, handlers.NewUserNotifier(ctx, projection.ApplyCustomConfig(userHandlerCustomConfig), commands, q, c, otpEmailTmpl, notificationWorkerConfig, queue)) From b46c41e4bf50af7f3873c6d0deb62206d77ec6e9 Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Mon, 2 Jun 2025 10:40:19 +0200 Subject: [PATCH 076/123] fix(settings): fix for setting restricted languages (#9947) # Which Problems Are Solved Zitadel encounters a migration error when setting `restricted languages` and fails to start. # How the Problems Are Solved The problem is that there is a check that checks that at least one of the restricted languages is the same as the `default language`, however, in the `authz instance` (where the default language is pulled form) is never set. I've added code to set the `default language` in the `authz instance` # Additional Context - Closes https://github.com/zitadel/zitadel/issues/9787 --------- Co-authored-by: Livio Spring --- internal/api/authz/instance.go | 30 +++++++++++++++++---------- internal/command/instance.go | 27 +++++++++++++----------- internal/command/instance_test.go | 34 ++++++++++++++++--------------- 3 files changed, 52 insertions(+), 39 deletions(-) diff --git a/internal/api/authz/instance.go b/internal/api/authz/instance.go index 7ee8d605ca..0fe6d6c8aa 100644 --- a/internal/api/authz/instance.go +++ b/internal/api/authz/instance.go @@ -9,9 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/feature" ) -var ( - emptyInstance = &instance{} -) +var emptyInstance = &instance{} type Instance interface { InstanceID() string @@ -33,13 +31,13 @@ type InstanceVerifier interface { } type instance struct { - id string - domain string - projectID string - appID string - clientID string - orgID string - features feature.Features + id string + projectID string + appID string + clientID string + orgID string + defaultLanguage language.Tag + features feature.Features } func (i *instance) Block() *bool { @@ -67,7 +65,7 @@ func (i *instance) ConsoleApplicationID() string { } func (i *instance) DefaultLanguage() language.Tag { - return language.Und + return i.defaultLanguage } func (i *instance) DefaultOrganisationID() string { @@ -106,6 +104,16 @@ func WithInstanceID(ctx context.Context, id string) context.Context { return context.WithValue(ctx, instanceKey, &instance{id: id}) } +func WithDefaultLanguage(ctx context.Context, defaultLanguage language.Tag) context.Context { + i, ok := ctx.Value(instanceKey).(*instance) + if !ok { + i = new(instance) + } + + i.defaultLanguage = defaultLanguage + return context.WithValue(ctx, instanceKey, i) +} + func WithConsole(ctx context.Context, projectID, appID string) context.Context { i, ok := ctx.Value(instanceKey).(*instance) if !ok { diff --git a/internal/command/instance.go b/internal/command/instance.go index d71be53468..cfafb1d298 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -221,7 +221,7 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str if err := setup.generateIDs(c.idGenerator); err != nil { return "", "", nil, nil, err } - ctx = contextWithInstanceSetupInfo(ctx, setup.zitadel.instanceID, setup.zitadel.projectID, setup.zitadel.consoleAppID, c.externalDomain) + ctx = contextWithInstanceSetupInfo(ctx, setup.zitadel.instanceID, setup.zitadel.projectID, setup.zitadel.consoleAppID, c.externalDomain, setup.DefaultLanguage) validations, pat, machineKey, err := setUpInstance(ctx, c, setup) if err != nil { @@ -255,19 +255,22 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str return setup.zitadel.instanceID, token, machineKey, details, nil } -func contextWithInstanceSetupInfo(ctx context.Context, instanceID, projectID, consoleAppID, externalDomain string) context.Context { - return authz.WithConsole( - authz.SetCtxData( - http.WithRequestedHost( - authz.WithInstanceID( - ctx, - instanceID), - externalDomain, +func contextWithInstanceSetupInfo(ctx context.Context, instanceID, projectID, consoleAppID, externalDomain string, defaultLanguage language.Tag) context.Context { + return authz.WithDefaultLanguage( + authz.WithConsole( + authz.SetCtxData( + http.WithRequestedHost( + authz.WithInstanceID( + ctx, + instanceID), + externalDomain, + ), + authz.CtxData{ResourceOwner: instanceID}, ), - authz.CtxData{ResourceOwner: instanceID}, + projectID, + consoleAppID, ), - projectID, - consoleAppID, + defaultLanguage, ) } diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index 16e51d844d..2b82818a7e 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -345,6 +345,7 @@ func instanceElementsEvents(ctx context.Context, instanceID, instanceName string instance.NewSecretGeneratorAddedEvent(ctx, &instanceAgg.Aggregate, domain.SecretGeneratorTypeOTPEmail, 8, 5*time.Minute, false, false, true, false), } } + func instanceElementsConfig() *SecretGenerators { return &SecretGenerators{ ClientSecret: &crypto.GeneratorConfig{Length: 64, IncludeLowerLetters: true, IncludeUpperLetters: true, IncludeDigits: true}, @@ -668,22 +669,23 @@ func TestCommandSide_setupMinimalInterfaces(t *testing.T) { eventstore: expectEventstore( slices.Concat( projectFilters(), - []expect{expectPush( - projectAddedEvents(context.Background(), - "INSTANCE", - "ORG", - "PROJECT", - "owner", - false, - )..., - ), + []expect{ + expectPush( + projectAddedEvents(context.Background(), + "INSTANCE", + "ORG", + "PROJECT", + "owner", + false, + )..., + ), }, )..., ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, projectClientIDs()...), }, args: args{ - ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN"), + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), instanceAgg: instance.NewAggregate("INSTANCE"), orgAgg: org.NewAggregate("ORG"), owner: "owner", @@ -767,7 +769,7 @@ func TestCommandSide_setupAdmins(t *testing.T) { }, }, args: args{ - ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN"), + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), instanceAgg: instance.NewAggregate("INSTANCE"), orgAgg: org.NewAggregate("ORG"), human: instanceSetupHumanConfig(), @@ -806,7 +808,7 @@ func TestCommandSide_setupAdmins(t *testing.T) { keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN"), + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), instanceAgg: instance.NewAggregate("INSTANCE"), orgAgg: org.NewAggregate("ORG"), machine: instanceSetupMachineConfig(), @@ -855,7 +857,7 @@ func TestCommandSide_setupAdmins(t *testing.T) { keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN"), + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), instanceAgg: instance.NewAggregate("INSTANCE"), orgAgg: org.NewAggregate("ORG"), machine: instanceSetupMachineConfig(), @@ -972,7 +974,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ - ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN"), + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), instanceAgg: instance.NewAggregate("INSTANCE"), orgName: "ZITADEL", machine: &AddMachine{ @@ -1097,7 +1099,7 @@ func TestCommandSide_setupInstanceElements(t *testing.T) { ), }, args: args{ - ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN"), + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), instanceAgg: instance.NewAggregate("INSTANCE"), setup: setupInstanceElementsConfig(), }, @@ -1183,7 +1185,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { }, }, args: args{ - ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN"), + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), setup: setupInstanceConfig(), }, res: res{ From b3d22dba0535f3818b69d2f8f62216f5e0365695 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Mon, 2 Jun 2025 17:29:56 +0200 Subject: [PATCH 077/123] docs(10016): cockroach compatibility (#10010) # Which Problems Are Solved If the sql statement of technical advisory 10016 gets executed on cockroach the following error is raised: ``` ERROR: WITH clause "fixed" does not return any columns SQLSTATE: 0A000 HINT: missing RETURNING clause? ``` # How the Problems Are Solved Fixed the statement by adding `returning` to statement --- docs/docs/support/advisory/a10016.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/docs/support/advisory/a10016.md b/docs/docs/support/advisory/a10016.md index 84dd1cd34c..38d73e6078 100644 --- a/docs/docs/support/advisory/a10016.md +++ b/docs/docs/support/advisory/a10016.md @@ -78,6 +78,7 @@ with and s.aggregate_id = b.aggregate_id and s.aggregate_type = b.aggregate_type and s.sequence = b.sequence + returning * ) select b.projection_name, From ae1a2e93c1e10a05d43762654ba8b4c8daed6a3d Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Mon, 2 Jun 2025 18:27:53 +0200 Subject: [PATCH 078/123] feat(api): moving organization API resourced based (#9943) --- cmd/start/start.go | 2 +- docs/docusaurus.config.js | 8 + docs/sidebars.js | 13 + internal/api/grpc/instance/converter.go | 4 +- .../v2beta/integration_test/instance_test.go | 5 +- .../v2beta/integration_test/query_test.go | 5 +- internal/api/grpc/management/org_converter.go | 4 +- internal/api/grpc/management/user.go | 4 + internal/api/grpc/metadata/v2beta/metadata.go | 49 + internal/api/grpc/object/v2beta/converter.go | 55 + internal/api/grpc/org/v2beta/helper.go | 256 +++ .../org/v2beta/integration_test/org_test.go | 1815 ++++++++++++++++- internal/api/grpc/org/v2beta/org.go | 238 ++- internal/api/grpc/org/v2beta/org_test.go | 45 +- internal/api/grpc/org/v2beta/server.go | 4 + internal/query/org_metadata.go | 1 - proto/zitadel/admin.proto | 28 +- proto/zitadel/management.proto | 258 +-- proto/zitadel/metadata/v2beta/metadata.proto | 57 + proto/zitadel/org/v2beta/org.proto | 169 ++ proto/zitadel/org/v2beta/org_service.proto | 825 +++++++- 21 files changed, 3542 insertions(+), 303 deletions(-) create mode 100644 internal/api/grpc/metadata/v2beta/metadata.go create mode 100644 internal/api/grpc/org/v2beta/helper.go create mode 100644 proto/zitadel/metadata/v2beta/metadata.proto create mode 100644 proto/zitadel/org/v2beta/org.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index af76b29e99..2fc1fb8413 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -470,7 +470,7 @@ func startAPIs( if err := apis.RegisterService(ctx, settings_v2beta.CreateServer(commands, queries)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, org_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { + if err := apis.RegisterService(ctx, org_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, feature_v2beta.CreateServer(commands, queries)); err != nil { diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index c161d38d9f..43830eafd0 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -342,6 +342,14 @@ module.exports = { categoryLinkSource: "auto", }, }, + org_v2beta: { + specPath: ".artifacts/openapi/zitadel/org/v2beta/org_service.swagger.json", + outputDir: "docs/apis/resources/org_service_v2beta", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, project_v2beta: { specPath: ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", outputDir: "docs/apis/resources/project_service_v2", diff --git a/docs/sidebars.js b/docs/sidebars.js index b7a399ecf1..f9b97703e5 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -10,6 +10,7 @@ const sidebar_api_oidc_service_v2 = require("./docs/apis/resources/oidc_service_ const sidebar_api_settings_service_v2 = require("./docs/apis/resources/settings_service_v2/sidebar.ts").default const sidebar_api_feature_service_v2 = require("./docs/apis/resources/feature_service_v2/sidebar.ts").default const sidebar_api_org_service_v2 = require("./docs/apis/resources/org_service_v2/sidebar.ts").default +const sidebar_api_org_service_v2beta = require("./docs/apis/resources/org_service_v2beta/sidebar.ts").default const sidebar_api_idp_service_v2 = require("./docs/apis/resources/idp_service_v2/sidebar.ts").default const sidebar_api_actions_v2 = require("./docs/apis/resources/action_service_v2/sidebar.ts").default const sidebar_api_project_service_v2 = require("./docs/apis/resources/project_service_v2/sidebar.ts").default @@ -791,6 +792,18 @@ module.exports = { }, items: sidebar_api_org_service_v2, }, + { + type: "category", + label: "Organization (Beta)", + link: { + type: "generated-index", + title: "Organization Service beta API", + slug: "/apis/resources/org_service/v2beta", + description: + "This API is intended to manage organizations for ZITADEL. \n", + }, + items: sidebar_api_org_service_v2beta, + }, { type: "category", label: "Identity Provider", diff --git a/internal/api/grpc/instance/converter.go b/internal/api/grpc/instance/converter.go index 4094da4a77..b894a064ff 100644 --- a/internal/api/grpc/instance/converter.go +++ b/internal/api/grpc/instance/converter.go @@ -28,7 +28,7 @@ func InstanceToPb(instance *query.Instance) *instance_pb.Instance { Name: instance.Name, Domains: DomainsToPb(instance.Domains), Version: build.Version(), - State: instance_pb.State_STATE_RUNNING, //TODO: change when delete is implemented + State: instance_pb.State_STATE_RUNNING, // TODO: change when delete is implemented } } @@ -44,7 +44,7 @@ func InstanceDetailToPb(instance *query.Instance) *instance_pb.InstanceDetail { Name: instance.Name, Domains: DomainsToPb(instance.Domains), Version: build.Version(), - State: instance_pb.State_STATE_RUNNING, //TODO: change when delete is implemented + State: instance_pb.State_STATE_RUNNING, // TODO: change when delete is implemented } } diff --git a/internal/api/grpc/instance/v2beta/integration_test/instance_test.go b/internal/api/grpc/instance/v2beta/integration_test/instance_test.go index 5187bbc78d..ae277c6d13 100644 --- a/internal/api/grpc/instance/v2beta/integration_test/instance_test.go +++ b/internal/api/grpc/instance/v2beta/integration_test/instance_test.go @@ -9,10 +9,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/zitadel/internal/integration" - instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) func TestDeleteInstace(t *testing.T) { diff --git a/internal/api/grpc/instance/v2beta/integration_test/query_test.go b/internal/api/grpc/instance/v2beta/integration_test/query_test.go index 0828b006e3..e59a16a932 100644 --- a/internal/api/grpc/instance/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/instance/v2beta/integration_test/query_test.go @@ -11,12 +11,13 @@ import ( "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/zitadel/zitadel/internal/integration" filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" "github.com/zitadel/zitadel/pkg/grpc/object/v2" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" ) func TestGetInstance(t *testing.T) { diff --git a/internal/api/grpc/management/org_converter.go b/internal/api/grpc/management/org_converter.go index 879b5e0763..03de84cdf4 100644 --- a/internal/api/grpc/management/org_converter.go +++ b/internal/api/grpc/management/org_converter.go @@ -26,7 +26,7 @@ func ListOrgDomainsRequestToModel(req *mgmt_pb.ListOrgDomainsRequest) (*query.Or Limit: limit, Asc: asc, }, - //SortingColumn: //TODO: sorting + // SortingColumn: //TODO: sorting Queries: queries, }, nil } @@ -89,7 +89,7 @@ func ListOrgMembersRequestToModel(ctx context.Context, req *mgmt_pb.ListOrgMembe Offset: offset, Limit: limit, Asc: asc, - //SortingColumn: //TODO: sorting + // SortingColumn: //TODO: sorting }, Queries: queries, }, diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 5b82eb5afe..f318051e63 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -901,6 +901,7 @@ func (s *Server) ListHumanLinkedIDPs(ctx context.Context, req *mgmt_pb.ListHuman Details: obj_grpc.ToListDetails(res.Count, res.Sequence, res.LastRun), }, nil } + func (s *Server) RemoveHumanLinkedIDP(ctx context.Context, req *mgmt_pb.RemoveHumanLinkedIDPRequest) (*mgmt_pb.RemoveHumanLinkedIDPResponse, error) { objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveHumanLinkedIDPRequestToDomain(ctx, req)) if err != nil { @@ -947,18 +948,21 @@ func cascadingIAMMembership(membership *query.IAMMembership) *command.CascadingI } return &command.CascadingIAMMembership{IAMID: membership.IAMID} } + func cascadingOrgMembership(membership *query.OrgMembership) *command.CascadingOrgMembership { if membership == nil { return nil } return &command.CascadingOrgMembership{OrgID: membership.OrgID} } + func cascadingProjectMembership(membership *query.ProjectMembership) *command.CascadingProjectMembership { if membership == nil { return nil } return &command.CascadingProjectMembership{ProjectID: membership.ProjectID} } + func cascadingProjectGrantMembership(membership *query.ProjectGrantMembership) *command.CascadingProjectGrantMembership { if membership == nil { return nil diff --git a/internal/api/grpc/metadata/v2beta/metadata.go b/internal/api/grpc/metadata/v2beta/metadata.go new file mode 100644 index 0000000000..57da21dfd2 --- /dev/null +++ b/internal/api/grpc/metadata/v2beta/metadata.go @@ -0,0 +1,49 @@ +package metadata + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + v2beta_object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + meta_pb "github.com/zitadel/zitadel/pkg/grpc/metadata/v2beta" +) + +// code in this file is copied from internal/api/grpc/metadata/metadata.go + +func OrgMetadataListToPb(dataList []*query.OrgMetadata) []*meta_pb.Metadata { + mds := make([]*meta_pb.Metadata, len(dataList)) + for i, data := range dataList { + mds[i] = OrgMetadataToPb(data) + } + return mds +} + +func OrgMetadataToPb(data *query.OrgMetadata) *meta_pb.Metadata { + return &meta_pb.Metadata{ + Key: data.Key, + Value: data.Value, + CreationDate: timestamppb.New(data.CreationDate), + ChangeDate: timestamppb.New(data.ChangeDate), + } +} + +func OrgMetadataQueriesToQuery(queries []*meta_pb.MetadataQuery) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = OrgMetadataQueryToQuery(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func OrgMetadataQueryToQuery(metadataQuery *meta_pb.MetadataQuery) (query.SearchQuery, error) { + switch q := metadataQuery.Query.(type) { + case *meta_pb.MetadataQuery_KeyQuery: + return query.NewOrgMetadataKeySearchQuery(q.KeyQuery.Key, v2beta_object.TextMethodToQuery(q.KeyQuery.Method)) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "METAD-fdg23", "List.Query.Invalid") + } +} diff --git a/internal/api/grpc/object/v2beta/converter.go b/internal/api/grpc/object/v2beta/converter.go index 9b14bb677a..73d5f18843 100644 --- a/internal/api/grpc/object/v2beta/converter.go +++ b/internal/api/grpc/object/v2beta/converter.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + org_pb "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) func DomainToDetailsPb(objectDetail *domain.ObjectDetails) *object.Details { @@ -34,6 +35,7 @@ func ToListDetails(response query.SearchResponse) *object.ListDetails { return details } + func ListQueryToQuery(query *object.ListQuery) (offset, limit uint64, asc bool) { if query == nil { return 0, 0, false @@ -73,3 +75,56 @@ func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison { return -1 } } + +func ListQueryToModel(query *object.ListQuery) (offset, limit uint64, asc bool) { + if query == nil { + return 0, 0, false + } + return query.Offset, uint64(query.Limit), query.Asc +} + +func DomainsToPb(domains []*query.Domain) []*org_pb.Domain { + d := make([]*org_pb.Domain, len(domains)) + for i, domain := range domains { + d[i] = DomainToPb(domain) + } + return d +} + +func DomainToPb(d *query.Domain) *org_pb.Domain { + return &org_pb.Domain{ + OrganizationId: d.OrgID, + DomainName: d.Domain, + IsVerified: d.IsVerified, + IsPrimary: d.IsPrimary, + ValidationType: DomainValidationTypeFromModel(d.ValidationType), + } +} + +func DomainValidationTypeFromModel(validationType domain.OrgDomainValidationType) org_pb.DomainValidationType { + switch validationType { + case domain.OrgDomainValidationTypeDNS: + return org_pb.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS + case domain.OrgDomainValidationTypeHTTP: + return org_pb.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP + case domain.OrgDomainValidationTypeUnspecified: + // added to please golangci-lint + return org_pb.DomainValidationType_DOMAIN_VALIDATION_TYPE_UNSPECIFIED + default: + return org_pb.DomainValidationType_DOMAIN_VALIDATION_TYPE_UNSPECIFIED + } +} + +func DomainValidationTypeToDomain(validationType org_pb.DomainValidationType) domain.OrgDomainValidationType { + switch validationType { + case org_pb.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP: + return domain.OrgDomainValidationTypeHTTP + case org_pb.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS: + return domain.OrgDomainValidationTypeDNS + case org_pb.DomainValidationType_DOMAIN_VALIDATION_TYPE_UNSPECIFIED: + // added to please golangci-lint + return domain.OrgDomainValidationTypeUnspecified + default: + return domain.OrgDomainValidationTypeUnspecified + } +} diff --git a/internal/api/grpc/org/v2beta/helper.go b/internal/api/grpc/org/v2beta/helper.go new file mode 100644 index 0000000000..39bad0dae2 --- /dev/null +++ b/internal/api/grpc/org/v2beta/helper.go @@ -0,0 +1,256 @@ +package org + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + // TODO fix below + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + metadata "github.com/zitadel/zitadel/internal/api/grpc/metadata/v2beta" + v2beta_object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + v2beta "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +// NOTE: most of this code is copied from `internal/api/grpc/admin/*`, as we will eventually axe the previous versons of the API, +// we will have code duplication until then + +func listOrgRequestToModel(systemDefaults systemdefaults.SystemDefaults, request *v2beta_org.ListOrganizationsRequest) (*query.OrgSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(systemDefaults, request.Pagination) + if err != nil { + return nil, err + } + queries, err := OrgQueriesToModel(request.Filter) + if err != nil { + return nil, err + } + return &query.OrgSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + SortingColumn: FieldNameToOrgColumn(request.SortingColumn), + Asc: asc, + }, + Queries: queries, + }, nil +} + +func OrganizationViewToPb(org *query.Org) *v2beta_org.Organization { + return &v2beta_org.Organization{ + Id: org.ID, + State: OrgStateToPb(org.State), + Name: org.Name, + PrimaryDomain: org.Domain, + CreationDate: timestamppb.New(org.CreationDate), + ChangedDate: timestamppb.New(org.ChangeDate), + } +} + +func OrgStateToPb(state domain.OrgState) v2beta_org.OrgState { + switch state { + case domain.OrgStateActive: + return v2beta_org.OrgState_ORG_STATE_ACTIVE + case domain.OrgStateInactive: + return v2beta_org.OrgState_ORG_STATE_INACTIVE + case domain.OrgStateRemoved: + // added to please golangci-lint + return v2beta_org.OrgState_ORG_STATE_REMOVED + case domain.OrgStateUnspecified: + // added to please golangci-lint + return v2beta_org.OrgState_ORG_STATE_UNSPECIFIED + default: + return v2beta_org.OrgState_ORG_STATE_UNSPECIFIED + } +} + +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrganizationResponse, err error) { + admins := make([]*org.CreatedAdmin, len(createdOrg.CreatedAdmins)) + for i, admin := range createdOrg.CreatedAdmins { + admins[i] = &org.CreatedAdmin{ + UserId: admin.ID, + EmailCode: admin.EmailCode, + PhoneCode: admin.PhoneCode, + } + } + return &org.CreateOrganizationResponse{ + CreationDate: timestamppb.New(createdOrg.ObjectDetails.EventDate), + Id: createdOrg.ObjectDetails.ResourceOwner, + CreatedAdmins: admins, + }, nil +} + +func OrgViewsToPb(orgs []*query.Org) []*v2beta_org.Organization { + o := make([]*v2beta_org.Organization, len(orgs)) + for i, org := range orgs { + o[i] = OrganizationViewToPb(org) + } + return o +} + +func OrgQueriesToModel(queries []*v2beta_org.OrganizationSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = OrgQueryToModel(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func OrgQueryToModel(apiQuery *v2beta_org.OrganizationSearchFilter) (query.SearchQuery, error) { + switch q := apiQuery.Filter.(type) { + case *v2beta_org.OrganizationSearchFilter_DomainFilter: + return query.NewOrgVerifiedDomainSearchQuery(v2beta_object.TextMethodToQuery(q.DomainFilter.Method), q.DomainFilter.Domain) + case *v2beta_org.OrganizationSearchFilter_NameFilter: + return query.NewOrgNameSearchQuery(v2beta_object.TextMethodToQuery(q.NameFilter.Method), q.NameFilter.Name) + case *v2beta_org.OrganizationSearchFilter_StateFilter: + return query.NewOrgStateSearchQuery(OrgStateToDomain(q.StateFilter.State)) + case *v2beta_org.OrganizationSearchFilter_IdFilter: + return query.NewOrgIDSearchQuery(q.IdFilter.Id) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ORG-vR9nC", "List.Query.Invalid") + } +} + +func OrgStateToDomain(state v2beta_org.OrgState) domain.OrgState { + switch state { + case v2beta_org.OrgState_ORG_STATE_ACTIVE: + return domain.OrgStateActive + case v2beta_org.OrgState_ORG_STATE_INACTIVE: + return domain.OrgStateInactive + case v2beta_org.OrgState_ORG_STATE_REMOVED: + // added to please golangci-lint + return domain.OrgStateRemoved + case v2beta_org.OrgState_ORG_STATE_UNSPECIFIED: + fallthrough + default: + return domain.OrgStateUnspecified + } +} + +func FieldNameToOrgColumn(fieldName v2beta_org.OrgFieldName) query.Column { + switch fieldName { + case v2beta_org.OrgFieldName_ORG_FIELD_NAME_NAME: + return query.OrgColumnName + case v2beta_org.OrgFieldName_ORG_FIELD_NAME_CREATION_DATE: + return query.OrgColumnCreationDate + case v2beta_org.OrgFieldName_ORG_FIELD_NAME_UNSPECIFIED: + return query.Column{} + default: + return query.Column{} + } +} + +func ListOrgDomainsRequestToModel(systemDefaults systemdefaults.SystemDefaults, request *org.ListOrganizationDomainsRequest) (*query.OrgDomainSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(systemDefaults, request.Pagination) + if err != nil { + return nil, err + } + queries, err := DomainQueriesToModel(request.Filters) + if err != nil { + return nil, err + } + return &query.OrgDomainSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + // SortingColumn: //TODO: sorting + Queries: queries, + }, nil +} + +func ListQueryToModel(query *v2beta.ListQuery) (offset, limit uint64, asc bool) { + if query == nil { + return 0, 0, false + } + return query.Offset, uint64(query.Limit), query.Asc +} + +func DomainQueriesToModel(queries []*v2beta_org.DomainSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = DomainQueryToModel(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func DomainQueryToModel(searchQuery *v2beta_org.DomainSearchFilter) (query.SearchQuery, error) { + switch q := searchQuery.Filter.(type) { + case *v2beta_org.DomainSearchFilter_DomainNameFilter: + return query.NewOrgDomainDomainSearchQuery(v2beta_object.TextMethodToQuery(q.DomainNameFilter.Method), q.DomainNameFilter.Name) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ORG-Ags89", "List.Query.Invalid") + } +} + +func RemoveOrgDomainRequestToDomain(ctx context.Context, req *v2beta_org.DeleteOrganizationDomainRequest) *domain.OrgDomain { + return &domain.OrgDomain{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.OrganizationId, + }, + Domain: req.Domain, + } +} + +func GenerateOrgDomainValidationRequestToDomain(ctx context.Context, req *v2beta_org.GenerateOrganizationDomainValidationRequest) *domain.OrgDomain { + return &domain.OrgDomain{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.OrganizationId, + }, + Domain: req.Domain, + ValidationType: v2beta_object.DomainValidationTypeToDomain(req.Type), + } +} + +func ValidateOrgDomainRequestToDomain(ctx context.Context, req *v2beta_org.VerifyOrganizationDomainRequest) *domain.OrgDomain { + return &domain.OrgDomain{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.OrganizationId, + }, + Domain: req.Domain, + } +} + +func BulkSetOrgMetadataToDomain(req *v2beta_org.SetOrganizationMetadataRequest) []*domain.Metadata { + metadata := make([]*domain.Metadata, len(req.Metadata)) + for i, data := range req.Metadata { + metadata[i] = &domain.Metadata{ + Key: data.Key, + Value: data.Value, + } + } + return metadata +} + +func ListOrgMetadataToDomain(systemDefaults systemdefaults.SystemDefaults, request *v2beta_org.ListOrganizationMetadataRequest) (*query.OrgMetadataSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(systemDefaults, request.Pagination) + if err != nil { + return nil, err + } + queries, err := metadata.OrgMetadataQueriesToQuery(request.Filter) + if err != nil { + return nil, err + } + return &query.OrgMetadataSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + Queries: queries, + }, nil +} diff --git a/internal/api/grpc/org/v2beta/integration_test/org_test.go b/internal/api/grpc/org/v2beta/integration_test/org_test.go index a2b2bf6047..4e0ec26121 100644 --- a/internal/api/grpc/org/v2beta/integration_test/org_test.go +++ b/internal/api/grpc/org/v2beta/integration_test/org_test.go @@ -4,7 +4,9 @@ package org_test import ( "context" + "errors" "os" + "strings" "testing" "time" @@ -14,7 +16,10 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/admin" + v2beta_object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" "github.com/zitadel/zitadel/pkg/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) @@ -22,7 +27,7 @@ import ( var ( CTX context.Context Instance *integration.Instance - Client org.OrganizationServiceClient + Client v2beta_org.OrganizationServiceClient User *user.AddHumanUserResponse ) @@ -40,20 +45,21 @@ func TestMain(m *testing.M) { }()) } -func TestServer_AddOrganization(t *testing.T) { +func TestServer_CreateOrganization(t *testing.T) { idpResp := Instance.AddGenericOAuthProvider(CTX, Instance.DefaultOrg.Id) tests := []struct { name string ctx context.Context - req *org.AddOrganizationRequest - want *org.AddOrganizationResponse + req *v2beta_org.CreateOrganizationRequest + id string + want *v2beta_org.CreateOrganizationResponse wantErr bool }{ { name: "missing permission", - ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), - req: &org.AddOrganizationRequest{ + ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + req: &v2beta_org.CreateOrganizationRequest{ Name: "name", Admins: nil, }, @@ -62,7 +68,7 @@ func TestServer_AddOrganization(t *testing.T) { { name: "empty name", ctx: CTX, - req: &org.AddOrganizationRequest{ + req: &v2beta_org.CreateOrganizationRequest{ Name: "", Admins: nil, }, @@ -71,34 +77,22 @@ func TestServer_AddOrganization(t *testing.T) { { name: "invalid admin type", ctx: CTX, - req: &org.AddOrganizationRequest{ + req: &v2beta_org.CreateOrganizationRequest{ Name: gofakeit.AppName(), - Admins: []*org.AddOrganizationRequest_Admin{ + Admins: []*v2beta_org.CreateOrganizationRequest_Admin{ {}, }, }, wantErr: true, }, - { - name: "no admin, custom org ID", - ctx: CTX, - req: &org.AddOrganizationRequest{ - Name: gofakeit.AppName(), - OrgId: gu.Ptr("custom-org-ID"), - }, - want: &org.AddOrganizationResponse{ - OrganizationId: "custom-org-ID", - CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{}, - }, - }, { name: "admin with init", ctx: CTX, - req: &org.AddOrganizationRequest{ + req: &v2beta_org.CreateOrganizationRequest{ Name: gofakeit.AppName(), - Admins: []*org.AddOrganizationRequest_Admin{ + Admins: []*v2beta_org.CreateOrganizationRequest_Admin{ { - UserType: &org.AddOrganizationRequest_Admin_Human{ + UserType: &v2beta_org.CreateOrganizationRequest_Admin_Human{ Human: &user_v2beta.AddHumanUserRequest{ Profile: &user_v2beta.SetHumanProfile{ GivenName: "firstname", @@ -115,9 +109,9 @@ func TestServer_AddOrganization(t *testing.T) { }, }, }, - want: &org.AddOrganizationResponse{ - OrganizationId: integration.NotEmpty, - CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + want: &v2beta_org.CreateOrganizationResponse{ + Id: integration.NotEmpty, + CreatedAdmins: []*v2beta_org.CreatedAdmin{ { UserId: integration.NotEmpty, EmailCode: gu.Ptr(integration.NotEmpty), @@ -129,14 +123,14 @@ func TestServer_AddOrganization(t *testing.T) { { name: "existing user and new human with idp", ctx: CTX, - req: &org.AddOrganizationRequest{ + req: &v2beta_org.CreateOrganizationRequest{ Name: gofakeit.AppName(), - Admins: []*org.AddOrganizationRequest_Admin{ + Admins: []*v2beta_org.CreateOrganizationRequest_Admin{ { - UserType: &org.AddOrganizationRequest_Admin_UserId{UserId: User.GetUserId()}, + UserType: &v2beta_org.CreateOrganizationRequest_Admin_UserId{UserId: User.GetUserId()}, }, { - UserType: &org.AddOrganizationRequest_Admin_Human{ + UserType: &v2beta_org.CreateOrganizationRequest_Admin_Human{ Human: &user_v2beta.AddHumanUserRequest{ Profile: &user_v2beta.SetHumanProfile{ GivenName: "firstname", @@ -160,8 +154,8 @@ func TestServer_AddOrganization(t *testing.T) { }, }, }, - want: &org.AddOrganizationResponse{ - CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + want: &v2beta_org.CreateOrganizationResponse{ + CreatedAdmins: []*v2beta_org.CreatedAdmin{ // a single admin is expected, because the first provided already exists { UserId: integration.NotEmpty, @@ -169,25 +163,36 @@ func TestServer_AddOrganization(t *testing.T) { }, }, }, + { + name: "create with ID", + ctx: CTX, + id: "custom_id", + req: &v2beta_org.CreateOrganizationRequest{ + Name: gofakeit.AppName(), + Id: gu.Ptr("custom_id"), + }, + want: &v2beta_org.CreateOrganizationResponse{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := Client.AddOrganization(tt.ctx, tt.req) + got, err := Client.CreateOrganization(tt.ctx, tt.req) if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) + if tt.id != "" { + require.Equal(t, tt.id, got.Id) + } + // check details - assert.NotZero(t, got.GetDetails().GetSequence()) - gotCD := got.GetDetails().GetChangeDate().AsTime() + gotCD := got.GetCreationDate().AsTime() now := time.Now() assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) - assert.NotEmpty(t, got.GetDetails().GetResourceOwner()) // organization id must be the same as the resourceOwner - assert.Equal(t, got.GetDetails().GetResourceOwner(), got.GetOrganizationId()) // check the admins require.Len(t, got.GetCreatedAdmins(), len(tt.want.GetCreatedAdmins())) @@ -199,7 +204,1739 @@ func TestServer_AddOrganization(t *testing.T) { } } -func assertCreatedAdmin(t *testing.T, expected, got *org.AddOrganizationResponse_CreatedAdmin) { +func TestServer_UpdateOrganization(t *testing.T) { + orgs, orgsName, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return + } + orgId := orgs[0].Id + orgName := orgsName[0] + + tests := []struct { + name string + ctx context.Context + req *v2beta_org.UpdateOrganizationRequest + want *v2beta_org.UpdateOrganizationResponse + wantErr bool + }{ + { + name: "update org with new name", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.UpdateOrganizationRequest{ + Id: orgId, + Name: "new org name", + }, + }, + { + name: "update org with same name", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.UpdateOrganizationRequest{ + Id: orgId, + Name: orgName, + }, + }, + { + name: "update org with non existent org id", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.UpdateOrganizationRequest{ + Id: "non existant org id", + // Name: "", + }, + wantErr: true, + }, + { + name: "update org with no id", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.UpdateOrganizationRequest{ + Id: "", + Name: orgName, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.UpdateOrganization(tt.ctx, tt.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + // check details + gotCD := got.GetChangeDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + }) + } +} + +func TestServer_ListOrganizations(t *testing.T) { + testStartTimestamp := time.Now() + ListOrgIinstance := integration.NewInstance(CTX) + listOrgIAmOwnerCtx := ListOrgIinstance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + listOrgClient := ListOrgIinstance.Client.OrgV2beta + + noOfOrgs := 3 + orgs, orgsName, err := createOrgs(listOrgIAmOwnerCtx, listOrgClient, noOfOrgs) + if err != nil { + require.NoError(t, err) + return + } + + // deactivat org[1] + _, err = listOrgClient.DeactivateOrganization(listOrgIAmOwnerCtx, &v2beta_org.DeactivateOrganizationRequest{ + Id: orgs[1].Id, + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + query []*v2beta_org.OrganizationSearchFilter + want []*v2beta_org.Organization + err error + }{ + { + name: "list organizations, without required permissions", + ctx: ListOrgIinstance.WithAuthorization(CTX, integration.UserTypeNoPermission), + err: errors.New("membership not found"), + }, + { + name: "list organizations happy path, no filter", + ctx: listOrgIAmOwnerCtx, + want: []*v2beta_org.Organization{ + { + // default org + Name: "testinstance", + }, + { + Id: orgs[0].Id, + Name: orgsName[0], + }, + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + { + Id: orgs[2].Id, + Name: orgsName[2], + }, + }, + }, + { + name: "list organizations by id happy path", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_IdFilter{ + IdFilter: &v2beta_org.OrgIDFilter{ + Id: orgs[1].Id, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + { + name: "list organizations by state active", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_StateFilter{ + StateFilter: &v2beta_org.OrgStateFilter{ + State: v2beta_org.OrgState_ORG_STATE_ACTIVE, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + // default org + Name: "testinstance", + }, + { + Id: orgs[0].Id, + Name: orgsName[0], + }, + { + Id: orgs[2].Id, + Name: orgsName[2], + }, + }, + }, + { + name: "list organizations by state inactive", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_StateFilter{ + StateFilter: &v2beta_org.OrgStateFilter{ + State: v2beta_org.OrgState_ORG_STATE_INACTIVE, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + { + name: "list organizations by id bad id", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_IdFilter{ + IdFilter: &v2beta_org.OrgIDFilter{ + Id: "bad id", + }, + }, + }, + }, + }, + { + name: "list organizations specify org name equals", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_NameFilter{ + NameFilter: &v2beta_org.OrgNameFilter{ + Name: orgsName[1], + Method: v2beta_object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + { + name: "list organizations specify org name contains", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_NameFilter{ + NameFilter: &v2beta_org.OrgNameFilter{ + Name: func() string { + return orgsName[1][1 : len(orgsName[1])-2] + }(), + Method: v2beta_object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + { + name: "list organizations specify org name contains IGNORE CASE", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_NameFilter{ + NameFilter: &v2beta_org.OrgNameFilter{ + Name: func() string { + return strings.ToUpper(orgsName[1][1 : len(orgsName[1])-2]) + }(), + Method: v2beta_object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + { + name: "list organizations specify domain name equals", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &org.OrganizationSearchFilter_DomainFilter{ + DomainFilter: &org.OrgDomainFilter{ + Domain: func() string { + listOrgRes, err := listOrgClient.ListOrganizations(listOrgIAmOwnerCtx, &v2beta_org.ListOrganizationsRequest{ + Filter: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_IdFilter{ + IdFilter: &v2beta_org.OrgIDFilter{ + Id: orgs[1].Id, + }, + }, + }, + }, + }) + require.NoError(t, err) + domain := listOrgRes.Organizations[0].PrimaryDomain + return domain + }(), + Method: v2beta_object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + { + name: "list organizations specify domain name contains", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &org.OrganizationSearchFilter_DomainFilter{ + DomainFilter: &org.OrgDomainFilter{ + Domain: func() string { + domain := strings.ToLower(strings.ReplaceAll(orgsName[1][1:len(orgsName[1])-2], " ", "-")) + return domain + }(), + Method: v2beta_object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + { + name: "list organizations specify org name contains IGNORE CASE", + ctx: listOrgIAmOwnerCtx, + query: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &org.OrganizationSearchFilter_DomainFilter{ + DomainFilter: &org.OrgDomainFilter{ + Domain: func() string { + domain := strings.ToUpper(strings.ReplaceAll(orgsName[1][1:len(orgsName[1])-2], " ", "-")) + return domain + }(), + Method: v2beta_object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE, + }, + }, + }, + }, + want: []*v2beta_org.Organization{ + { + Id: orgs[1].Id, + Name: orgsName[1], + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := listOrgClient.ListOrganizations(tt.ctx, &v2beta_org.ListOrganizationsRequest{ + Filter: tt.query, + }) + if tt.err != nil { + require.ErrorContains(t, err, tt.err.Error()) + return + } + require.NoError(ttt, err) + + require.Equal(ttt, uint64(len(tt.want)), got.Pagination.GetTotalResult()) + + foundOrgs := 0 + for _, got := range got.Organizations { + for _, org := range tt.want { + + // created/chagned date + gotCD := got.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(ttt, gotCD, testStartTimestamp, now.Add(time.Minute)) + gotCD = got.GetChangedDate().AsTime() + assert.WithinRange(ttt, gotCD, testStartTimestamp, now.Add(time.Minute)) + + // default org + if org.Name == got.Name && got.Name == "testinstance" { + foundOrgs += 1 + continue + } + + if org.Name == got.Name && + org.Id == got.Id { + foundOrgs += 1 + } + } + } + require.Equal(ttt, len(tt.want), foundOrgs) + }, retryDuration, tick, "timeout waiting for expected organizations being created") + }) + } +} + +func TestServer_DeleteOrganization(t *testing.T) { + tests := []struct { + name string + ctx context.Context + createOrgFunc func() string + req *v2beta_org.DeleteOrganizationRequest + want *v2beta_org.DeleteOrganizationResponse + dontCheckTime bool + err error + }{ + { + name: "delete org no permission", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + createOrgFunc: func() string { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + return orgs[0].Id + }, + req: &v2beta_org.DeleteOrganizationRequest{}, + err: errors.New("membership not found"), + }, + { + name: "delete org happy path", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + createOrgFunc: func() string { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + return orgs[0].Id + }, + req: &v2beta_org.DeleteOrganizationRequest{}, + }, + { + name: "delete already deleted org", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + createOrgFunc: func() string { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + // delete org + _, err = Client.DeleteOrganization(CTX, &v2beta_org.DeleteOrganizationRequest{Id: orgs[0].Id}) + require.NoError(t, err) + + return orgs[0].Id + }, + req: &v2beta_org.DeleteOrganizationRequest{}, + dontCheckTime: true, + }, + { + name: "delete non existent org", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.DeleteOrganizationRequest{ + Id: "non existent org id", + }, + dontCheckTime: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.createOrgFunc != nil { + tt.req.Id = tt.createOrgFunc() + } + + got, err := Client.DeleteOrganization(tt.ctx, tt.req) + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + return + } + require.NoError(t, err) + + // check details + gotCD := got.GetDeletionDate().AsTime() + if !tt.dontCheckTime { + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + } + }) + } +} + +func TestServer_DeactivateReactivateNonExistentOrganization(t *testing.T) { + ctx := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + // deactivate non existent organization + _, err := Client.DeactivateOrganization(ctx, &v2beta_org.DeactivateOrganizationRequest{ + Id: "non existent organization", + }) + require.Contains(t, err.Error(), "Organisation not found") + + // reactivate non existent organization + _, err = Client.ActivateOrganization(ctx, &v2beta_org.ActivateOrganizationRequest{ + Id: "non existent organization", + }) + require.Contains(t, err.Error(), "Organisation not found") +} + +func TestServer_ActivateOrganization(t *testing.T) { + tests := []struct { + name string + ctx context.Context + testFunc func() string + err error + }{ + { + name: "Activate, happy path", + ctx: CTX, + testFunc: func() string { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create orgs") + return "" + } + orgId := orgs[0].Id + + // 2. deactivate organization once + deactivate_res, err := Client.DeactivateOrganization(CTX, &v2beta_org.DeactivateOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + gotCD := deactivate_res.GetChangeDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // 3. check organization state is deactivated + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + listOrgRes, err := Client.ListOrganizations(CTX, &v2beta_org.ListOrganizationsRequest{ + Filter: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_IdFilter{ + IdFilter: &v2beta_org.OrgIDFilter{ + Id: orgId, + }, + }, + }, + }, + }) + require.NoError(ttt, err) + require.Equal(ttt, v2beta_org.OrgState_ORG_STATE_INACTIVE, listOrgRes.Organizations[0].State) + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + return orgId + }, + }, + { + name: "Activate, no permission", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + testFunc: func() string { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create orgs") + return "" + } + orgId := orgs[0].Id + return orgId + }, + // BUG: this needs changing + err: errors.New("membership not found"), + }, + { + name: "Activate, not existing", + ctx: CTX, + testFunc: func() string { + return "non-existing-org-id" + }, + err: errors.New("Organisation not found"), + }, + { + name: "Activate, already activated", + ctx: CTX, + testFunc: func() string { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create orgs") + return "" + } + orgId := orgs[0].Id + return orgId + }, + err: errors.New("Organisation is already active"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var orgId string + if tt.testFunc != nil { + orgId = tt.testFunc() + } + _, err := Client.ActivateOrganization(tt.ctx, &v2beta_org.ActivateOrganizationRequest{ + Id: orgId, + }) + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestServer_DeactivateOrganization(t *testing.T) { + tests := []struct { + name string + ctx context.Context + testFunc func() string + err error + }{ + { + name: "Deactivate, happy path", + ctx: CTX, + testFunc: func() string { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create orgs") + return "" + } + orgId := orgs[0].Id + + return orgId + }, + }, + { + name: "Deactivate, no permission", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + testFunc: func() string { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create orgs") + return "" + } + orgId := orgs[0].Id + return orgId + }, + // BUG: this needs changing + err: errors.New("membership not found"), + }, + { + name: "Deactivate, not existing", + ctx: CTX, + testFunc: func() string { + return "non-existing-org-id" + }, + err: errors.New("Organisation not found"), + }, + { + name: "Deactivate, already deactivated", + ctx: CTX, + testFunc: func() string { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create orgs") + return "" + } + orgId := orgs[0].Id + + // 2. deactivate organization once + deactivate_res, err := Client.DeactivateOrganization(CTX, &v2beta_org.DeactivateOrganizationRequest{ + Id: orgId, + }) + require.NoError(t, err) + gotCD := deactivate_res.GetChangeDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // 3. check organization state is deactivated + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + listOrgRes, err := Client.ListOrganizations(CTX, &v2beta_org.ListOrganizationsRequest{ + Filter: []*v2beta_org.OrganizationSearchFilter{ + { + Filter: &v2beta_org.OrganizationSearchFilter_IdFilter{ + IdFilter: &v2beta_org.OrgIDFilter{ + Id: orgId, + }, + }, + }, + }, + }) + require.NoError(ttt, err) + require.Equal(ttt, v2beta_org.OrgState_ORG_STATE_INACTIVE, listOrgRes.Organizations[0].State) + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + return orgId + }, + err: errors.New("Organisation is already deactivated"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var orgId string + orgId = tt.testFunc() + _, err := Client.DeactivateOrganization(tt.ctx, &v2beta_org.DeactivateOrganizationRequest{ + Id: orgId, + }) + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestServer_AddOrganizationDomain(t *testing.T) { + tests := []struct { + name string + ctx context.Context + domain string + testFunc func() string + err error + }{ + { + name: "add org domain, happy path", + domain: gofakeit.URL(), + testFunc: func() string { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + orgId := orgs[0].Id + return orgId + }, + }, + { + name: "add org domain, twice", + domain: gofakeit.URL(), + testFunc: func() string { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + orgId := orgs[0].Id + + domain := gofakeit.URL() + // 2. add domain + addOrgDomainRes, err := Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + // check details + gotCD := addOrgDomainRes.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // check domain added + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + queryRes, err := Client.ListOrganizationDomains(CTX, &v2beta_org.ListOrganizationDomainsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + found := false + for _, res := range queryRes.Domains { + if res.DomainName == domain { + found = true + } + } + require.True(t, found, "unable to find added domain") + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + return orgId + }, + }, + { + name: "add org domain to non existent org", + domain: gofakeit.URL(), + testFunc: func() string { + return "non-existing-org-id" + }, + // BUG: should return a error + err: nil, + }, + } + + for _, tt := range tests { + var orgId string + t.Run(tt.name, func(t *testing.T) { + orgId = tt.testFunc() + }) + addOrgDomainRes, err := Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: tt.domain, + }) + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + } else { + require.NoError(t, err) + // check details + gotCD := addOrgDomainRes.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + } + } +} + +func TestServer_ListOrganizationDomains(t *testing.T) { + domain := gofakeit.URL() + tests := []struct { + name string + ctx context.Context + domain string + testFunc func() string + err error + }{ + { + name: "list org domain, happy path", + domain: domain, + testFunc: func() string { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + orgId := orgs[0].Id + // 2. add domain + addOrgDomainRes, err := Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + // check details + gotCD := addOrgDomainRes.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + return orgId + }, + }, + } + + for _, tt := range tests { + var orgId string + t.Run(tt.name, func(t *testing.T) { + orgId = tt.testFunc() + }) + + var err error + var queryRes *v2beta_org.ListOrganizationDomainsResponse + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + queryRes, err = Client.ListOrganizationDomains(CTX, &v2beta_org.ListOrganizationDomainsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + found := false + for _, res := range queryRes.Domains { + if res.DomainName == tt.domain { + found = true + } + } + require.True(t, found, "unable to find added domain") + }, retryDuration, tick, "timeout waiting for adding domain") + + } +} + +func TestServer_DeleteOerganizationDomain(t *testing.T) { + domain := gofakeit.URL() + tests := []struct { + name string + ctx context.Context + domain string + testFunc func() string + err error + }{ + { + name: "delete org domain, happy path", + domain: domain, + testFunc: func() string { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + orgId := orgs[0].Id + + // 2. add domain + addOrgDomainRes, err := Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + // check details + gotCD := addOrgDomainRes.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // check domain added + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + queryRes, err := Client.ListOrganizationDomains(CTX, &v2beta_org.ListOrganizationDomainsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + found := false + for _, res := range queryRes.Domains { + if res.DomainName == domain { + found = true + } + } + require.True(t, found, "unable to find added domain") + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + return orgId + }, + }, + { + name: "delete org domain, twice", + domain: gofakeit.URL(), + testFunc: func() string { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return "" + } + orgId := orgs[0].Id + + domain := gofakeit.URL() + // 2. add domain + addOrgDomainRes, err := Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + // check details + gotCD := addOrgDomainRes.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // check domain added + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + queryRes, err := Client.ListOrganizationDomains(CTX, &v2beta_org.ListOrganizationDomainsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + found := false + for _, res := range queryRes.Domains { + if res.DomainName == domain { + found = true + } + } + require.True(t, found, "unable to find added domain") + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + _, err = Client.DeleteOrganizationDomain(CTX, &v2beta_org.DeleteOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + + return orgId + }, + err: errors.New("Domain doesn't exist on organization"), + }, + { + name: "delete org domain to non existent org", + domain: gofakeit.URL(), + testFunc: func() string { + return "non-existing-org-id" + }, + // BUG: + err: errors.New("Domain doesn't exist on organization"), + }, + } + + for _, tt := range tests { + var orgId string + t.Run(tt.name, func(t *testing.T) { + orgId = tt.testFunc() + }) + + _, err := Client.DeleteOrganizationDomain(CTX, &v2beta_org.DeleteOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: tt.domain, + }) + + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + } else { + require.NoError(t, err) + } + } +} + +func TestServer_AddListDeleteOrganizationDomain(t *testing.T) { + tests := []struct { + name string + testFunc func() + }{ + { + name: "add org domain, re-add org domain", + testFunc: func() { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return + } + orgId := orgs[0].Id + // ctx := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + domain := gofakeit.URL() + // 2. add domain + addOrgDomainRes, err := Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + // check details + gotCD := addOrgDomainRes.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // 3. re-add domain + _, err = Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + // TODO remove error for adding already existing domain + // require.NoError(t, err) + require.Contains(t, err.Error(), "Errors.Already.Exists") + // check details + // gotCD = addOrgDomainRes.GetDetails().GetChangeDate().AsTime() + // now = time.Now() + // assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // 4. check domain is added + queryRes, err := Client.ListOrganizationDomains(CTX, &v2beta_org.ListOrganizationDomainsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + found := false + for _, res := range queryRes.Domains { + if res.DomainName == domain { + found = true + } + } + require.True(t, found, "unable to find added domain") + }, + }, + { + name: "add org domain, delete org domain, re-delete org domain", + testFunc: func() { + // 1. create organization + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return + } + orgId := orgs[0].Id + + domain := gofakeit.URL() + // 2. add domain + addOrgDomainRes, err := Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + // check details + gotCD := addOrgDomainRes.GetCreationDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // 2. delete organisation domain + deleteOrgDomainRes, err := Client.DeleteOrganizationDomain(CTX, &v2beta_org.DeleteOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + // check details + gotCD = deleteOrgDomainRes.GetDeletionDate().AsTime() + now = time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(t *assert.CollectT) { + // 3. check organization domain deleted + queryRes, err := Client.ListOrganizationDomains(CTX, &v2beta_org.ListOrganizationDomainsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + found := false + for _, res := range queryRes.Domains { + if res.DomainName == domain { + found = true + } + } + require.False(t, found, "deleted domain found") + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + // 4. redelete organisation domain + _, err = Client.DeleteOrganizationDomain(CTX, &v2beta_org.DeleteOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + // TODO remove error for deleting org domain already deleted + // require.NoError(t, err) + require.Contains(t, err.Error(), "Domain doesn't exist on organization") + // check details + // gotCD = deleteOrgDomainRes.GetDetails().GetChangeDate().AsTime() + // now = time.Now() + // assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + // 5. check organization domain deleted + queryRes, err := Client.ListOrganizationDomains(CTX, &v2beta_org.ListOrganizationDomainsRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + found := false + for _, res := range queryRes.Domains { + if res.DomainName == domain { + found = true + } + } + require.False(t, found, "deleted domain found") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.testFunc() + }) + } +} + +func TestServer_ValidateOrganizationDomain(t *testing.T) { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return + } + orgId := orgs[0].Id + + _, err = Instance.Client.Admin.UpdateDomainPolicy(CTX, &admin.UpdateDomainPolicyRequest{ + ValidateOrgDomains: true, + }) + if err != nil && !strings.Contains(err.Error(), "Organisation is already deactivated") { + require.NoError(t, err) + } + + domain := gofakeit.URL() + _, err = Client.AddOrganizationDomain(CTX, &v2beta_org.AddOrganizationDomainRequest{ + OrganizationId: orgId, + Domain: domain, + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + req *v2beta_org.GenerateOrganizationDomainValidationRequest + err error + }{ + { + name: "validate org http happy path", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ + OrganizationId: orgId, + Domain: domain, + Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, + }, + }, + { + name: "validate org http non existnetn org id", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ + OrganizationId: "non existent org id", + Domain: domain, + Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, + }, + // BUG: this should be 'organization does not exist' + err: errors.New("Domain doesn't exist on organization"), + }, + { + name: "validate org dns happy path", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ + OrganizationId: orgId, + Domain: domain, + Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS, + }, + }, + { + name: "validate org dns non existnetn org id", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ + OrganizationId: "non existent org id", + Domain: domain, + Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS, + }, + // BUG: this should be 'organization does not exist' + err: errors.New("Domain doesn't exist on organization"), + }, + { + name: "validate org non existnetn domain", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ + OrganizationId: orgId, + Domain: "non existent domain", + Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, + }, + err: errors.New("Domain doesn't exist on organization"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.GenerateOrganizationDomainValidation(tt.ctx, tt.req) + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + return + } + require.NoError(t, err) + + require.NotEmpty(t, got.Token) + require.Contains(t, got.Url, domain) + }) + } +} + +func TestServer_SetOrganizationMetadata(t *testing.T) { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return + } + orgId := orgs[0].Id + + tests := []struct { + name string + ctx context.Context + setupFunc func() + orgId string + key string + value string + err error + }{ + { + name: "set org metadata", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + orgId: orgId, + key: "key1", + value: "value1", + }, + { + name: "set org metadata on non existant org", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + orgId: "non existant orgid", + key: "key2", + value: "value2", + err: errors.New("Organisation not found"), + }, + { + name: "update org metadata", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key3", + Value: []byte("value3"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + key: "key4", + value: "value4", + }, + { + name: "update org metadata with same value", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key5", + Value: []byte("value5"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + key: "key5", + value: "value5", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setupFunc != nil { + tt.setupFunc() + } + got, err := Client.SetOrganizationMetadata(tt.ctx, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: tt.orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: tt.key, + Value: []byte(tt.value), + }, + }, + }) + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + return + } + require.NoError(t, err) + + // check details + gotCD := got.GetSetDate().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // check metadata + listMetadataRes, err := Client.ListOrganizationMetadata(tt.ctx, &v2beta_org.ListOrganizationMetadataRequest{ + OrganizationId: orgId, + }) + require.NoError(t, err) + foundMetadata := false + foundMetadataKeyCount := 0 + for _, res := range listMetadataRes.Metadata { + if res.Key == tt.key { + foundMetadataKeyCount += 1 + } + if res.Key == tt.key && + string(res.Value) == tt.value { + foundMetadata = true + } + } + require.True(ttt, foundMetadata, "unable to find added metadata") + require.Equal(ttt, 1, foundMetadataKeyCount, "same metadata key found multiple times") + }, retryDuration, tick, "timeout waiting for expected organizations being created") + }) + } +} + +func TestServer_ListOrganizationMetadata(t *testing.T) { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return + } + orgId := orgs[0].Id + + tests := []struct { + name string + ctx context.Context + setupFunc func() + orgId string + keyValuPars []struct { + key string + value string + } + }{ + { + name: "list org metadata happy path", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key1", + Value: []byte("value1"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + keyValuPars: []struct{ key, value string }{ + { + key: "key1", + value: "value1", + }, + }, + }, + { + name: "list multiple org metadata happy path", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key2", + Value: []byte("value2"), + }, + { + Key: "key3", + Value: []byte("value3"), + }, + { + Key: "key4", + Value: []byte("value4"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + keyValuPars: []struct{ key, value string }{ + { + key: "key2", + value: "value2", + }, + { + key: "key3", + value: "value3", + }, + { + key: "key4", + value: "value4", + }, + }, + }, + { + name: "list org metadata for non existent org", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + orgId: "non existent orgid", + keyValuPars: []struct{ key, value string }{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setupFunc != nil { + tt.setupFunc() + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, err := Client.ListOrganizationMetadata(tt.ctx, &v2beta_org.ListOrganizationMetadataRequest{ + OrganizationId: tt.orgId, + }) + require.NoError(t, err) + + foundMetadataCount := 0 + for _, kv := range tt.keyValuPars { + for _, res := range got.Metadata { + if res.Key == kv.key && + string(res.Value) == kv.value { + foundMetadataCount += 1 + } + } + } + require.Equal(t, len(tt.keyValuPars), foundMetadataCount) + }, retryDuration, tick, "timeout waiting for expected organizations being created") + }) + } +} + +func TestServer_DeleteOrganizationMetadata(t *testing.T) { + orgs, _, err := createOrgs(CTX, Client, 1) + if err != nil { + assert.Fail(t, "unable to create org") + return + } + orgId := orgs[0].Id + + tests := []struct { + name string + ctx context.Context + setupFunc func() + orgId string + metadataToDelete []struct { + key string + value string + } + metadataToRemain []struct { + key string + value string + } + err error + }{ + { + name: "delete org metadata happy path", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key1", + Value: []byte("value1"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + metadataToDelete: []struct{ key, value string }{ + { + key: "key1", + value: "value1", + }, + }, + }, + { + name: "delete multiple org metadata happy path", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key2", + Value: []byte("value2"), + }, + { + Key: "key3", + Value: []byte("value3"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + metadataToDelete: []struct{ key, value string }{ + { + key: "key2", + value: "value2", + }, + { + key: "key3", + value: "value3", + }, + }, + }, + { + name: "delete some org metadata but not all", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key4", + Value: []byte("value4"), + }, + // key5 should not be deleted + { + Key: "key5", + Value: []byte("value5"), + }, + { + Key: "key6", + Value: []byte("value6"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + metadataToDelete: []struct{ key, value string }{ + { + key: "key4", + value: "value4", + }, + { + key: "key6", + value: "value6", + }, + }, + metadataToRemain: []struct{ key, value string }{ + { + key: "key5", + value: "value5", + }, + }, + }, + { + name: "delete org metadata that does not exist", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key88", + Value: []byte("value74"), + }, + { + Key: "key5888", + Value: []byte("value8885"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: orgId, + // TODO: this error message needs to be either removed or changed + err: errors.New("Metadata list is empty"), + }, + { + name: "delete org metadata for org that does not exist", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + setupFunc: func() { + _, err := Client.SetOrganizationMetadata(CTX, &v2beta_org.SetOrganizationMetadataRequest{ + OrganizationId: orgId, + Metadata: []*v2beta_org.Metadata{ + { + Key: "key88", + Value: []byte("value74"), + }, + { + Key: "key5888", + Value: []byte("value8885"), + }, + }, + }) + require.NoError(t, err) + }, + orgId: "non existant org id", + // TODO: this error message needs to be either removed or changed + err: errors.New("Metadata list is empty"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setupFunc != nil { + tt.setupFunc() + } + + // check metadata exists + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + listOrgMetadataRes, err := Client.ListOrganizationMetadata(tt.ctx, &v2beta_org.ListOrganizationMetadataRequest{ + OrganizationId: tt.orgId, + }) + require.NoError(ttt, err) + foundMetadataCount := 0 + for _, kv := range tt.metadataToDelete { + for _, res := range listOrgMetadataRes.Metadata { + if res.Key == kv.key && + string(res.Value) == kv.value { + foundMetadataCount += 1 + } + } + } + require.Equal(ttt, len(tt.metadataToDelete), foundMetadataCount) + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + keys := make([]string, len(tt.metadataToDelete)) + for i, kvp := range tt.metadataToDelete { + keys[i] = kvp.key + } + + // run delete + _, err = Client.DeleteOrganizationMetadata(tt.ctx, &v2beta_org.DeleteOrganizationMetadataRequest{ + OrganizationId: tt.orgId, + Keys: keys, + }) + if tt.err != nil { + require.Contains(t, err.Error(), tt.err.Error()) + return + } + require.NoError(t, err) + + retryDuration, tick = integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // check metadata was definitely deleted + listOrgMetadataRes, err := Client.ListOrganizationMetadata(tt.ctx, &v2beta_org.ListOrganizationMetadataRequest{ + OrganizationId: tt.orgId, + }) + require.NoError(ttt, err) + foundMetadataCount := 0 + for _, kv := range tt.metadataToDelete { + for _, res := range listOrgMetadataRes.Metadata { + if res.Key == kv.key && + string(res.Value) == kv.value { + foundMetadataCount += 1 + } + } + } + require.Equal(ttt, foundMetadataCount, 0) + }, retryDuration, tick, "timeout waiting for expected organizations being created") + + // check metadata that should not be delted was not deleted + listOrgMetadataRes, err := Client.ListOrganizationMetadata(tt.ctx, &v2beta_org.ListOrganizationMetadataRequest{ + OrganizationId: tt.orgId, + }) + require.NoError(t, err) + foundMetadataCount := 0 + for _, kv := range tt.metadataToRemain { + for _, res := range listOrgMetadataRes.Metadata { + if res.Key == kv.key && + string(res.Value) == kv.value { + foundMetadataCount += 1 + } + } + } + require.Equal(t, len(tt.metadataToRemain), foundMetadataCount) + }) + } +} + +func createOrgs(ctx context.Context, client v2beta_org.OrganizationServiceClient, noOfOrgs int) ([]*v2beta_org.CreateOrganizationResponse, []string, error) { + var err error + orgs := make([]*v2beta_org.CreateOrganizationResponse, noOfOrgs) + orgsName := make([]string, noOfOrgs) + + for i := range noOfOrgs { + orgName := gofakeit.Name() + orgsName[i] = orgName + orgs[i], err = client.CreateOrganization(ctx, + &v2beta_org.CreateOrganizationRequest{ + Name: orgName, + }, + ) + if err != nil { + return nil, nil, err + } + } + + return orgs, orgsName, nil +} + +func assertCreatedAdmin(t *testing.T, expected, got *v2beta_org.CreatedAdmin) { if expected.GetUserId() != "" { assert.NotEmpty(t, got.GetUserId()) } else { diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go index 39730f827e..66198757cb 100644 --- a/internal/api/grpc/org/v2beta/org.go +++ b/internal/api/grpc/org/v2beta/org.go @@ -2,16 +2,23 @@ package org import ( "context" + "errors" + "google.golang.org/protobuf/types/known/timestamppb" + + metadata "github.com/zitadel/zitadel/internal/api/grpc/metadata/v2beta" object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" user "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) -func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) { - orgSetup, err := addOrganizationRequestToCommand(request) +func (s *Server) CreateOrganization(ctx context.Context, request *v2beta_org.CreateOrganizationRequest) (*v2beta_org.CreateOrganizationResponse, error) { + orgSetup, err := createOrganizationRequestToCommand(request) if err != nil { return nil, err } @@ -22,8 +29,182 @@ func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizati return createdOrganizationToPb(createdOrg) } -func addOrganizationRequestToCommand(request *org.AddOrganizationRequest) (*command.OrgSetup, error) { - admins, err := addOrganizationRequestAdminsToCommand(request.GetAdmins()) +func (s *Server) UpdateOrganization(ctx context.Context, request *v2beta_org.UpdateOrganizationRequest) (*v2beta_org.UpdateOrganizationResponse, error) { + org, err := s.command.ChangeOrg(ctx, request.Id, request.Name) + if err != nil { + return nil, err + } + + return &v2beta_org.UpdateOrganizationResponse{ + ChangeDate: timestamppb.New(org.EventDate), + }, nil +} + +func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.ListOrganizationsRequest) (*v2beta_org.ListOrganizationsResponse, error) { + queries, err := listOrgRequestToModel(s.systemDefaults, request) + if err != nil { + return nil, err + } + orgs, err := s.query.SearchOrgs(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return &v2beta_org.ListOrganizationsResponse{ + Organizations: OrgViewsToPb(orgs.Orgs), + Pagination: &filter.PaginationResponse{ + TotalResult: orgs.Count, + AppliedLimit: uint64(request.GetPagination().GetLimit()), + }, + }, nil +} + +func (s *Server) DeleteOrganization(ctx context.Context, request *v2beta_org.DeleteOrganizationRequest) (*v2beta_org.DeleteOrganizationResponse, error) { + details, err := s.command.RemoveOrg(ctx, request.Id) + if err != nil { + var notFoundError *zerrors.NotFoundError + if errors.As(err, ¬FoundError) { + return &v2beta_org.DeleteOrganizationResponse{}, nil + } + return nil, err + } + return &v2beta_org.DeleteOrganizationResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) SetOrganizationMetadata(ctx context.Context, request *v2beta_org.SetOrganizationMetadataRequest) (*v2beta_org.SetOrganizationMetadataResponse, error) { + result, err := s.command.BulkSetOrgMetadata(ctx, request.OrganizationId, BulkSetOrgMetadataToDomain(request)...) + if err != nil { + return nil, err + } + return &org.SetOrganizationMetadataResponse{ + SetDate: timestamppb.New(result.EventDate), + }, nil +} + +func (s *Server) ListOrganizationMetadata(ctx context.Context, request *v2beta_org.ListOrganizationMetadataRequest) (*v2beta_org.ListOrganizationMetadataResponse, error) { + metadataQueries, err := ListOrgMetadataToDomain(s.systemDefaults, request) + if err != nil { + return nil, err + } + res, err := s.query.SearchOrgMetadata(ctx, true, request.OrganizationId, metadataQueries, false) + if err != nil { + return nil, err + } + return &v2beta_org.ListOrganizationMetadataResponse{ + Metadata: metadata.OrgMetadataListToPb(res.Metadata), + Pagination: &filter.PaginationResponse{ + TotalResult: res.Count, + AppliedLimit: uint64(request.GetPagination().GetLimit()), + }, + }, nil +} + +func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *v2beta_org.DeleteOrganizationMetadataRequest) (*v2beta_org.DeleteOrganizationMetadataResponse, error) { + result, err := s.command.BulkRemoveOrgMetadata(ctx, request.OrganizationId, request.Keys...) + if err != nil { + return nil, err + } + return &v2beta_org.DeleteOrganizationMetadataResponse{ + DeletionDate: timestamppb.New(result.EventDate), + }, nil +} + +func (s *Server) DeactivateOrganization(ctx context.Context, request *org.DeactivateOrganizationRequest) (*org.DeactivateOrganizationResponse, error) { + objectDetails, err := s.command.DeactivateOrg(ctx, request.Id) + if err != nil { + return nil, err + } + return &org.DeactivateOrganizationResponse{ + ChangeDate: timestamppb.New(objectDetails.EventDate), + }, nil +} + +func (s *Server) ActivateOrganization(ctx context.Context, request *org.ActivateOrganizationRequest) (*org.ActivateOrganizationResponse, error) { + objectDetails, err := s.command.ReactivateOrg(ctx, request.Id) + if err != nil { + return nil, err + } + return &org.ActivateOrganizationResponse{ + ChangeDate: timestamppb.New(objectDetails.EventDate), + }, err +} + +func (s *Server) AddOrganizationDomain(ctx context.Context, request *org.AddOrganizationDomainRequest) (*org.AddOrganizationDomainResponse, error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) + if err != nil { + return nil, err + } + details, err := s.command.AddOrgDomain(ctx, request.OrganizationId, request.Domain, userIDs) + if err != nil { + return nil, err + } + return &org.AddOrganizationDomainResponse{ + CreationDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) ListOrganizationDomains(ctx context.Context, req *org.ListOrganizationDomainsRequest) (*org.ListOrganizationDomainsResponse, error) { + queries, err := ListOrgDomainsRequestToModel(s.systemDefaults, req) + if err != nil { + return nil, err + } + orgIDQuery, err := query.NewOrgDomainOrgIDSearchQuery(req.OrganizationId) + if err != nil { + return nil, err + } + queries.Queries = append(queries.Queries, orgIDQuery) + + domains, err := s.query.SearchOrgDomains(ctx, queries, false) + if err != nil { + return nil, err + } + return &org.ListOrganizationDomainsResponse{ + Domains: object.DomainsToPb(domains.Domains), + Pagination: &filter.PaginationResponse{ + TotalResult: domains.Count, + AppliedLimit: uint64(req.GetPagination().GetLimit()), + }, + }, nil +} + +func (s *Server) DeleteOrganizationDomain(ctx context.Context, req *org.DeleteOrganizationDomainRequest) (*org.DeleteOrganizationDomainResponse, error) { + details, err := s.command.RemoveOrgDomain(ctx, RemoveOrgDomainRequestToDomain(ctx, req)) + if err != nil { + return nil, err + } + return &org.DeleteOrganizationDomainResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, err +} + +func (s *Server) GenerateOrganizationDomainValidation(ctx context.Context, req *org.GenerateOrganizationDomainValidationRequest) (*org.GenerateOrganizationDomainValidationResponse, error) { + token, url, err := s.command.GenerateOrgDomainValidation(ctx, GenerateOrgDomainValidationRequestToDomain(ctx, req)) + if err != nil { + return nil, err + } + return &org.GenerateOrganizationDomainValidationResponse{ + Token: token, + Url: url, + }, nil +} + +func (s *Server) VerifyOrganizationDomain(ctx context.Context, request *org.VerifyOrganizationDomainRequest) (*org.VerifyOrganizationDomainResponse, error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) + if err != nil { + return nil, err + } + details, err := s.command.ValidateOrgDomain(ctx, ValidateOrgDomainRequestToDomain(ctx, request), userIDs) + if err != nil { + return nil, err + } + return &org.VerifyOrganizationDomainResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }, nil +} + +func createOrganizationRequestToCommand(request *v2beta_org.CreateOrganizationRequest) (*command.OrgSetup, error) { + admins, err := createOrganizationRequestAdminsToCommand(request.GetAdmins()) if err != nil { return nil, err } @@ -31,14 +212,14 @@ func addOrganizationRequestToCommand(request *org.AddOrganizationRequest) (*comm Name: request.GetName(), CustomDomain: "", Admins: admins, - OrgID: request.GetOrgId(), + OrgID: request.GetId(), }, nil } -func addOrganizationRequestAdminsToCommand(requestAdmins []*org.AddOrganizationRequest_Admin) (admins []*command.OrgSetupAdmin, err error) { +func createOrganizationRequestAdminsToCommand(requestAdmins []*v2beta_org.CreateOrganizationRequest_Admin) (admins []*command.OrgSetupAdmin, err error) { admins = make([]*command.OrgSetupAdmin, len(requestAdmins)) for i, admin := range requestAdmins { - admins[i], err = addOrganizationRequestAdminToCommand(admin) + admins[i], err = createOrganizationRequestAdminToCommand(admin) if err != nil { return nil, err } @@ -46,14 +227,14 @@ func addOrganizationRequestAdminsToCommand(requestAdmins []*org.AddOrganizationR return admins, nil } -func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admin) (*command.OrgSetupAdmin, error) { +func createOrganizationRequestAdminToCommand(admin *v2beta_org.CreateOrganizationRequest_Admin) (*command.OrgSetupAdmin, error) { switch a := admin.GetUserType().(type) { - case *org.AddOrganizationRequest_Admin_UserId: + case *v2beta_org.CreateOrganizationRequest_Admin_UserId: return &command.OrgSetupAdmin{ ID: a.UserId, Roles: admin.GetRoles(), }, nil - case *org.AddOrganizationRequest_Admin_Human: + case *v2beta_org.CreateOrganizationRequest_Admin_Human: human, err := user.AddUserRequestToAddHuman(a.Human) if err != nil { return nil, err @@ -63,22 +244,31 @@ func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admi Roles: admin.GetRoles(), }, nil default: - return nil, zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", a) + return nil, zerrors.ThrowUnimplementedf(nil, "ORGv2-SL2r8", "userType oneOf %T in method AddOrganization not implemented", a) } } -func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) { - admins := make([]*org.AddOrganizationResponse_CreatedAdmin, len(createdOrg.CreatedAdmins)) - for i, admin := range createdOrg.CreatedAdmins { - admins[i] = &org.AddOrganizationResponse_CreatedAdmin{ - UserId: admin.ID, - EmailCode: admin.EmailCode, - PhoneCode: admin.PhoneCode, - } +func (s *Server) getClaimedUserIDsOfOrgDomain(ctx context.Context, orgDomain, orgID string) ([]string, error) { + queries := make([]query.SearchQuery, 0, 2) + loginName, err := query.NewUserPreferredLoginNameSearchQuery("@"+orgDomain, query.TextEndsWithIgnoreCase) + if err != nil { + return nil, err } - return &org.AddOrganizationResponse{ - Details: object.DomainToDetailsPb(createdOrg.ObjectDetails), - OrganizationId: createdOrg.ObjectDetails.ResourceOwner, - CreatedAdmins: admins, - }, nil + queries = append(queries, loginName) + if orgID != "" { + owner, err := query.NewUserResourceOwnerSearchQuery(orgID, query.TextNotEquals) + if err != nil { + return nil, err + } + queries = append(queries, owner) + } + users, err := s.query.SearchUsers(ctx, &query.UserSearchQueries{Queries: queries}, nil) + if err != nil { + return nil, err + } + userIDs := make([]string, len(users.Users)) + for i, user := range users.Users { + userIDs[i] = user.ID + } + return userIDs, nil } diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go index 57ed05dfb2..2047f665a1 100644 --- a/internal/api/grpc/org/v2beta/org_test.go +++ b/internal/api/grpc/org/v2beta/org_test.go @@ -12,14 +12,13 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" - object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func Test_addOrganizationRequestToCommand(t *testing.T) { +func Test_createOrganizationRequestToCommand(t *testing.T) { type args struct { - request *org.AddOrganizationRequest + request *org.CreateOrganizationRequest } tests := []struct { name string @@ -30,21 +29,21 @@ func Test_addOrganizationRequestToCommand(t *testing.T) { { name: "nil user", args: args{ - request: &org.AddOrganizationRequest{ + request: &org.CreateOrganizationRequest{ Name: "name", - Admins: []*org.AddOrganizationRequest_Admin{ + Admins: []*org.CreateOrganizationRequest_Admin{ {}, }, }, }, - wantErr: zerrors.ThrowUnimplementedf(nil, "ORGv2-SD2r1", "userType oneOf %T in method AddOrganization not implemented", nil), + wantErr: zerrors.ThrowUnimplementedf(nil, "ORGv2-SL2r8", "userType oneOf %T in method AddOrganization not implemented", nil), }, { name: "custom org ID", args: args{ - request: &org.AddOrganizationRequest{ - Name: "custom org ID", - OrgId: gu.Ptr("org-ID"), + request: &org.CreateOrganizationRequest{ + Name: "custom org ID", + Id: gu.Ptr("org-ID"), }, }, want: &command.OrgSetup{ @@ -57,11 +56,11 @@ func Test_addOrganizationRequestToCommand(t *testing.T) { { name: "user ID", args: args{ - request: &org.AddOrganizationRequest{ + request: &org.CreateOrganizationRequest{ Name: "name", - Admins: []*org.AddOrganizationRequest_Admin{ + Admins: []*org.CreateOrganizationRequest_Admin{ { - UserType: &org.AddOrganizationRequest_Admin_UserId{ + UserType: &org.CreateOrganizationRequest_Admin_UserId{ UserId: "userID", }, Roles: nil, @@ -82,11 +81,11 @@ func Test_addOrganizationRequestToCommand(t *testing.T) { { name: "human user", args: args{ - request: &org.AddOrganizationRequest{ + request: &org.CreateOrganizationRequest{ Name: "name", - Admins: []*org.AddOrganizationRequest_Admin{ + Admins: []*org.CreateOrganizationRequest_Admin{ { - UserType: &org.AddOrganizationRequest_Admin_Human{ + UserType: &org.CreateOrganizationRequest_Admin_Human{ Human: &user.AddHumanUserRequest{ Profile: &user.SetHumanProfile{ GivenName: "firstname", @@ -124,7 +123,7 @@ func Test_addOrganizationRequestToCommand(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := addOrganizationRequestToCommand(tt.args.request) + got, err := createOrganizationRequestToCommand(tt.args.request) require.ErrorIs(t, err, tt.wantErr) assert.Equal(t, tt.want, got) }) @@ -139,7 +138,7 @@ func Test_createdOrganizationToPb(t *testing.T) { tests := []struct { name string args args - want *org.AddOrganizationResponse + want *org.CreateOrganizationResponse wantErr error }{ { @@ -160,14 +159,10 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - want: &org.AddOrganizationResponse{ - Details: &object.Details{ - Sequence: 1, - ChangeDate: timestamppb.New(now), - ResourceOwner: "orgID", - }, - OrganizationId: "orgID", - CreatedAdmins: []*org.AddOrganizationResponse_CreatedAdmin{ + want: &org.CreateOrganizationResponse{ + CreationDate: timestamppb.New(now), + Id: "orgID", + CreatedAdmins: []*org.CreatedAdmin{ { UserId: "id", EmailCode: gu.Ptr("emailCode"), diff --git a/internal/api/grpc/org/v2beta/server.go b/internal/api/grpc/org/v2beta/server.go index 89dba81702..b7e8d4994f 100644 --- a/internal/api/grpc/org/v2beta/server.go +++ b/internal/api/grpc/org/v2beta/server.go @@ -6,6 +6,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" @@ -15,6 +16,7 @@ var _ org.OrganizationServiceServer = (*Server)(nil) type Server struct { org.UnimplementedOrganizationServiceServer + systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries checkPermission domain.PermissionCheck @@ -23,11 +25,13 @@ type Server struct { type Config struct{} func CreateServer( + systemDefaults systemdefaults.SystemDefaults, command *command.Commands, query *query.Queries, checkPermission domain.PermissionCheck, ) *Server { return &Server{ + systemDefaults: systemDefaults, command: command, query: query, checkPermission: checkPermission, diff --git a/internal/query/org_metadata.go b/internal/query/org_metadata.go index 84b204de2b..fe61ad51d9 100644 --- a/internal/query/org_metadata.go +++ b/internal/query/org_metadata.go @@ -194,7 +194,6 @@ func prepareOrgMetadataQuery() (sq.SelectBuilder, func(*sql.Row) (*OrgMetadata, &m.Key, &m.Value, ) - if err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, zerrors.ThrowNotFound(err, "QUERY-Rph32", "Errors.Metadata.NotFound") diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index 1e7f3b7407..d8c88d540b 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -307,7 +307,6 @@ service AdminService { }; } - // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/instance-service-list-custom-domains.api.mdx) instead to list custom domains rpc ListInstanceDomains(ListInstanceDomainsRequest) returns (ListInstanceDomainsResponse) { option (google.api.http) = { post: "/domains/_search"; @@ -320,12 +319,10 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "List Instance Domains"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are the URLs where ZITADEL is running."; - deprecated: true; + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are the URLs where ZITADEL is running." }; } - // Deprecated: Use [ListTrustedDomains](apis/resources/instance_service_v2/instance-service-list-trusted-domains.api.mdx) instead to list trusted domains rpc ListInstanceTrustedDomains(ListInstanceTrustedDomainsRequest) returns (ListInstanceTrustedDomainsResponse) { option (google.api.http) = { post: "/trusted_domains/_search"; @@ -338,12 +335,10 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "List Instance Trusted Domains"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts."; - deprecated: true; + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts." }; } - // Deprecated: Use [AddTrustedDomain](apis/resources/instance_service_v2/instance-service-add-trusted-domain.api.mdx) instead to add a trusted domain rpc AddInstanceTrustedDomain(AddInstanceTrustedDomainRequest) returns (AddInstanceTrustedDomainResponse) { option (google.api.http) = { post: "/trusted_domains"; @@ -357,12 +352,10 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "Add an Instance Trusted Domain"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts."; - deprecated: true; + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts." }; } - // Deprecated: Use [RemoveTrustedDomain](apis/resources/instance_service_v2/instance-service-remove-trusted-domain.api.mdx) instead to remove a trusted domain rpc RemoveInstanceTrustedDomain(RemoveInstanceTrustedDomainRequest) returns (RemoveInstanceTrustedDomainResponse) { option (google.api.http) = { delete: "/trusted_domains/{domain}"; @@ -375,8 +368,7 @@ service AdminService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Instance"; summary: "Remove an Instance Trusted Domain"; - description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts."; - deprecated: true; + description: "Returns a list of domains that are configured for this ZITADEL instance. These domains are trusted to be used as public hosts." }; } @@ -1245,6 +1237,7 @@ service AdminService { }; } + // Deprecated: use ListOrganization [apis/resources/org_service_v2beta/organization-service-list-organizations.api.mdx] API instead rpc ListOrgs(ListOrgsRequest) returns (ListOrgsResponse) { option (google.api.http) = { post: "/orgs/_search"; @@ -1264,7 +1257,8 @@ service AdminService { value: { description: "list of organizations matching the query"; }; - }; + } + deprecated: true; responses: { key: "400"; value: { @@ -1279,6 +1273,7 @@ service AdminService { }; } + // Deprecated: use CreateOrganization [apis/resources/org_service_v2beta/organization-service-create-organization.api.mdx] API instead rpc SetUpOrg(SetUpOrgRequest) returns (SetUpOrgResponse) { option (google.api.http) = { post: "/orgs/_setup"; @@ -1298,7 +1293,8 @@ service AdminService { value: { description: "org, user and user membership were created successfully"; }; - }; + } + deprecated: true; responses: { key: "400"; value: { @@ -1313,6 +1309,7 @@ service AdminService { }; } + // Deprecated: use DeleteOrganization [apis/resources/org_service_v2beta/organization-service-delete-organization.api.mdx] API instead rpc RemoveOrg(RemoveOrgRequest) returns (RemoveOrgResponse) { option (google.api.http) = { delete: "/orgs/{org_id}" @@ -1330,7 +1327,8 @@ service AdminService { value: { description: "org removed successfully"; }; - }; + } + deprecated: true; responses: { key: "400"; value: { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 3018ebe600..34a8384d39 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -2119,6 +2119,7 @@ service ManagementService { }; } + // Deprecated: use CreateOrganization [apis/resources/org_service_v2beta/organization-service-create-organization.api.mdx] API instead rpc AddOrg(AddOrgRequest) returns (AddOrgResponse) { option (google.api.http) = { post: "/orgs" @@ -2133,6 +2134,7 @@ service ManagementService { tags: "Organizations"; summary: "Create Organization"; description: "Create a new organization. Based on the given name a domain will be generated to be able to identify users within an organization." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2144,6 +2146,7 @@ service ManagementService { }; } + // Deprecated: use UpdateOrganization [apis/resources/org_service_v2beta/organization-service-update-organization.api.mdx] API instead rpc UpdateOrg(UpdateOrgRequest) returns (UpdateOrgResponse) { option (google.api.http) = { put: "/orgs/me" @@ -2158,6 +2161,7 @@ service ManagementService { tags: "Organizations"; summary: "Update Organization"; description: "Change the name of the organization." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2169,6 +2173,7 @@ service ManagementService { }; } + // Deprecated: use DeactivateOrganization [apis/resources/org_service_v2beta/organization-service-deactivate-organization.api.mdx] API instead rpc DeactivateOrg(DeactivateOrgRequest) returns (DeactivateOrgResponse) { option (google.api.http) = { post: "/orgs/me/_deactivate" @@ -2183,6 +2188,7 @@ service ManagementService { tags: "Organizations"; summary: "Deactivate Organization"; description: "Sets the state of my organization to deactivated. Users of this organization will not be able to log in." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2194,6 +2200,7 @@ service ManagementService { }; } + // Deprecated: use ActivateOrganization [apis/resources/org_service_v2beta/organization-service-activate-organization.api.mdx] API instead rpc ReactivateOrg(ReactivateOrgRequest) returns (ReactivateOrgResponse) { option (google.api.http) = { post: "/orgs/me/_reactivate" @@ -2208,6 +2215,7 @@ service ManagementService { tags: "Organizations"; summary: "Reactivate Organization"; description: "Set the state of my organization to active. The state of the organization has to be deactivated to perform the request. Users of this organization will be able to log in again." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2219,6 +2227,7 @@ service ManagementService { }; } + // Deprecated: use DeleteOrganization [apis/resources/org_service_v2beta/organization-service-delete-organization.api.mdx] API instead rpc RemoveOrg(RemoveOrgRequest) returns (RemoveOrgResponse) { option (google.api.http) = { delete: "/orgs/me" @@ -2232,6 +2241,7 @@ service ManagementService { tags: "Organizations"; summary: "Delete Organization"; description: "Deletes my organization and all its resources (Users, Projects, Grants to and from the org). Users of this organization will not be able to log in." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2243,6 +2253,7 @@ service ManagementService { }; } + // Deprecated: use SetOrganizationMetadata [apis/resources/org_service_v2beta/organization-service-set-organization-metadata.api.mdx] API instead rpc SetOrgMetadata(SetOrgMetadataRequest) returns (SetOrgMetadataResponse) { option (google.api.http) = { post: "/metadata/{key}" @@ -2258,6 +2269,7 @@ service ManagementService { tags: "Organization Metadata"; summary: "Set Organization Metadata"; description: "This endpoint either adds or updates a metadata value for the requested key. Make sure the value is base64 encoded." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2269,6 +2281,7 @@ service ManagementService { }; } + // Deprecated: use SetOrganizationMetadata [apis/resources/org_service_v2beta/organization-service-set-organization-metadata.api.mdx] API instead rpc BulkSetOrgMetadata(BulkSetOrgMetadataRequest) returns (BulkSetOrgMetadataResponse) { option (google.api.http) = { post: "/metadata/_bulk" @@ -2284,6 +2297,7 @@ service ManagementService { tags: "Organization Metadata"; summary: "Bulk Set Organization Metadata"; description: "This endpoint sets a list of metadata to the organization. Make sure the values are base64 encoded." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2295,6 +2309,7 @@ service ManagementService { }; } + // Deprecated: use ListOrganizationMetadata [apis/resources/org_service_v2beta/organization-service-list-organization-metadata.api.mdx] API instead rpc ListOrgMetadata(ListOrgMetadataRequest) returns (ListOrgMetadataResponse) { option (google.api.http) = { post: "/metadata/_search" @@ -2310,6 +2325,7 @@ service ManagementService { tags: "Organization Metadata"; summary: "Search Organization Metadata"; description: "Get the metadata of an organization filtered by your query." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2321,6 +2337,7 @@ service ManagementService { }; } + // Deprecated: use ListOrganizationMetadata [apis/resources/org_service_v2beta/organization-service-list-organization-metadata.api.mdx] API instead rpc GetOrgMetadata(GetOrgMetadataRequest) returns (GetOrgMetadataResponse) { option (google.api.http) = { get: "/metadata/{key}" @@ -2335,6 +2352,7 @@ service ManagementService { tags: "Organization Metadata"; summary: "Get Organization Metadata By Key"; description: "Get a metadata object from an organization by a specific key." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2346,6 +2364,7 @@ service ManagementService { }; } + // Deprecated: use DeleteOrganizationMetadata [apis/resources/org_service_v2beta/organization-service-delete-organization-metadata.api.mdx] API instead rpc RemoveOrgMetadata(RemoveOrgMetadataRequest) returns (RemoveOrgMetadataResponse) { option (google.api.http) = { delete: "/metadata/{key}" @@ -2360,6 +2379,7 @@ service ManagementService { tags: "Organization Metadata"; summary: "Delete Organization Metadata By Key"; description: "Remove a metadata object from an organization with a specific key." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2371,6 +2391,7 @@ service ManagementService { }; } + // Deprecated: use DeleteOrganizationMetadata [apis/resources/org_service_v2beta/organization-service-delete-organization-metadata.api.mdx] API instead rpc BulkRemoveOrgMetadata(BulkRemoveOrgMetadataRequest) returns (BulkRemoveOrgMetadataResponse) { option (google.api.http) = { delete: "/metadata/_bulk" @@ -2384,6 +2405,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Organizations"; tags: "Organization Metadata"; + deprecated: true summary: "Bulk Delete Metadata"; description: "Remove a list of metadata objects from an organization with a list of keys." parameters: { @@ -2397,31 +2419,7 @@ service ManagementService { }; } - rpc ListOrgDomains(ListOrgDomainsRequest) returns (ListOrgDomainsResponse) { - option (google.api.http) = { - post: "/orgs/me/domains/_search" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "org.read" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Organizations"; - summary: "Search Domains"; - description: "Returns the list of registered domains of an organization. The domains are used to identify to which organization a user belongs." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - + // Deprecated: use AddOrganizationDomain [apis/resources/org_service_v2beta/organization-service-add-organization-domain.api.mdx] API instead rpc AddOrgDomain(AddOrgDomainRequest) returns (AddOrgDomainResponse) { option (google.api.http) = { post: "/orgs/me/domains" @@ -2436,6 +2434,7 @@ service ManagementService { tags: "Organizations"; summary: "Add Domain"; description: "Add a new domain to an organization. The domains are used to identify to which organization a user belongs." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2447,6 +2446,34 @@ service ManagementService { }; } + // Deprecated: use ListOrganizationDomains [apis/resources/org_service_v2beta/organization-service-list-organization-domains.api.mdx] API instead + rpc ListOrgDomains(ListOrgDomainsRequest) returns (ListOrgDomainsResponse) { + option (google.api.http) = { + post: "/orgs/me/domains/_search" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "org.read" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Organizations"; + summary: "Search Domains"; + description: "Returns the list of registered domains of an organization. The domains are used to identify to which organization a user belongs." + deprecated: true + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to get/set a result of another organization include the header. Make sure the user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + }; + } + + // Deprecated: use DeleteOrganizationDomain [apis/resources/org_service_v2beta/organization-service-delete-organization-domain.api.mdx] API instead rpc RemoveOrgDomain(RemoveOrgDomainRequest) returns (RemoveOrgDomainResponse) { option (google.api.http) = { delete: "/orgs/me/domains/{domain}" @@ -2460,6 +2487,7 @@ service ManagementService { tags: "Organizations"; summary: "Remove Domain"; description: "Delete a new domain from an organization. The domains are used to identify to which organization a user belongs. If the uses use the domain for login, this will not be possible afterwards. They have to use another domain instead." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2471,6 +2499,7 @@ service ManagementService { }; } + // Deprecated: use GenerateOrganizationDomainValidation [apis/resources/org_service_v2beta/organization-service-generate-organization-domain-validation.api.mdx] API instead rpc GenerateOrgDomainValidation(GenerateOrgDomainValidationRequest) returns (GenerateOrgDomainValidationResponse) { option (google.api.http) = { post: "/orgs/me/domains/{domain}/validation/_generate" @@ -2485,6 +2514,7 @@ service ManagementService { tags: "Organizations"; summary: "Generate Domain Verification"; description: "Generate a new file to be able to verify your domain with DNS or HTTP challenge." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2496,6 +2526,7 @@ service ManagementService { }; } + // Deprecated: use VerifyOrganizationDomain [apis/resources/org_service_v2beta/organization-service-verify-organization-domain.api.mdx] API instead rpc ValidateOrgDomain(ValidateOrgDomainRequest) returns (ValidateOrgDomainResponse) { option (google.api.http) = { post: "/orgs/me/domains/{domain}/validation/_validate" @@ -2510,6 +2541,7 @@ service ManagementService { tags: "Organizations"; summary: "Verify Domain"; description: "Make sure you have added the required verification to your domain, depending on the method you have chosen (HTTP or DNS challenge). ZITADEL will check it and set the domain as verified if it was successful. A verify domain has to be unique." + deprecated: true parameters: { headers: { name: "x-zitadel-orgid"; @@ -2678,11 +2710,6 @@ service ManagementService { }; } - // Get Project By ID - // - // Deprecated: [Get Project](apis/resources/project_service_v2/project-service-get-project.api.mdx) to get project by ID. - // - // Returns a project owned by the organization (no granted projects). A Project is a vessel for different applications sharing the same role context. rpc GetProjectByID(GetProjectByIDRequest) returns (GetProjectByIDResponse) { option (google.api.http) = { get: "/projects/{id}" @@ -2695,7 +2722,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Get Project By ID"; + description: "Returns a project owned by the organization (no granted projects). A Project is a vessel for different applications sharing the same role context." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2707,11 +2735,6 @@ service ManagementService { }; } - // Get Granted Project By ID - // - // Deprecated: [List Projects](apis/resources/project_service_v2/project-service-list-projects.api.mdx) to get granted projects. - // - // Returns a project owned by another organization and granted to my organization. A Project is a vessel for different applications sharing the same role context. rpc GetGrantedProjectByID(GetGrantedProjectByIDRequest) returns (GetGrantedProjectByIDResponse) { option (google.api.http) = { get: "/granted_projects/{project_id}/grants/{grant_id}" @@ -2724,7 +2747,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Get Granted Project By ID"; + description: "Returns a project owned by another organization and granted to my organization. A Project is a vessel for different applications sharing the same role context." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2736,11 +2760,6 @@ service ManagementService { }; } - // List Projects - // - // Deprecated: [List Projects](apis/resources/project_service_v2/project-service-list-projects.api.mdx) to list all projects and granted projects. - // - // Lists projects my organization is the owner of (no granted projects). A Project is a vessel for different applications sharing the same role context. rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse) { option (google.api.http) = { post: "/projects/_search" @@ -2753,7 +2772,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Search Project"; + description: "Lists projects my organization is the owner of (no granted projects). A Project is a vessel for different applications sharing the same role context." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2765,11 +2785,6 @@ service ManagementService { }; } - // List Granted Projects - // - // Deprecated: [List Projects](apis/resources/project_service_v2/project-service-list-projects.api.mdx) to list all projects and granted projects. - // - // Lists projects my organization got granted from another organization. A Project is a vessel for different applications sharing the same role context. rpc ListGrantedProjects(ListGrantedProjectsRequest) returns (ListGrantedProjectsResponse) { option (google.api.http) = { post: "/granted_projects/_search" @@ -2782,7 +2797,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Search Granted Project"; + description: "Lists projects my organization got granted from another organization. A Project is a vessel for different applications sharing the same role context." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2844,11 +2860,6 @@ service ManagementService { }; } - // Create Project - // - // Deprecated: [Create Project](apis/resources/project_service_v2/project-service-create-project.api.mdx) to create a project. - // - // Create a new project. A Project is a vessel for different applications sharing the same role context. rpc AddProject(AddProjectRequest) returns (AddProjectResponse) { option (google.api.http) = { post: "/projects" @@ -2861,7 +2872,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Create Project"; + description: "Create a new project. A Project is a vessel for different applications sharing the same role context." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2873,11 +2885,6 @@ service ManagementService { }; } - // Update Project - // - // Deprecated: [Update Project](apis/resources/project_service_v2/project-service-update-project.api.mdx) to update a project. - // - // Update a project and its settings. A Project is a vessel for different applications sharing the same role context. rpc UpdateProject(UpdateProjectRequest) returns (UpdateProjectResponse) { option (google.api.http) = { put: "/projects/{id}" @@ -2891,7 +2898,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Update Project"; + description: "Update a project and its settings. A Project is a vessel for different applications sharing the same role context." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2903,11 +2911,6 @@ service ManagementService { }; } - // Deactivate Project - // - // Deprecated: [Deactivate Project](apis/resources/project_service_v2/project-service-deactivate-project.api.mdx) to deactivate a project. - // - // Set the state of a project to deactivated. Request returns an error if the project is already deactivated. rpc DeactivateProject(DeactivateProjectRequest) returns (DeactivateProjectResponse) { option (google.api.http) = { post: "/projects/{id}/_deactivate" @@ -2921,7 +2924,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Deactivate Project"; + description: "Set the state of a project to deactivated. Request returns an error if the project is already deactivated." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2933,11 +2937,6 @@ service ManagementService { }; } - // Activate Project - // - // Deprecated: [Activate Project](apis/resources/project_service_v2/project-service-activate-project.api.mdx) to activate a project. - // - // Set the state of a project to active. Request returns an error if the project is not deactivated. rpc ReactivateProject(ReactivateProjectRequest) returns (ReactivateProjectResponse) { option (google.api.http) = { post: "/projects/{id}/_reactivate" @@ -2951,7 +2950,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Reactivate Project"; + description: "Set the state of a project to active. Request returns an error if the project is not deactivated." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2963,11 +2963,6 @@ service ManagementService { }; } - // Remove Project - // - // Deprecated: [Delete Project](apis/resources/project_service_v2/project-service-delete-project.api.mdx) to remove a project. - // - // Project and all its sub-resources like project grants, applications, roles and user grants will be removed. rpc RemoveProject(RemoveProjectRequest) returns (RemoveProjectResponse) { option (google.api.http) = { delete: "/projects/{id}" @@ -2980,7 +2975,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Remove Project"; + description: "Project and all its sub-resources like project grants, applications, roles and user grants will be removed." parameters: { headers: { name: "x-zitadel-orgid"; @@ -2992,11 +2988,6 @@ service ManagementService { }; } - // Search Project Roles - // - // Deprecated: [List Project Roles](apis/resources/project_service_v2/project-service-list-project-roles.api.mdx) to get project roles. - // - // Returns all roles of a project matching the search query. rpc ListProjectRoles(ListProjectRolesRequest) returns (ListProjectRolesResponse) { option (google.api.http) = { post: "/projects/{project_id}/roles/_search" @@ -3010,7 +3001,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - deprecated: true; + summary: "Search Project Roles"; + description: "Returns all roles of a project matching the search query." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3022,11 +3014,6 @@ service ManagementService { }; } - // Add Project Role - // - // Deprecated: [Add Project Role](apis/resources/project_service_v2/project-service-add-project-role.api.mdx) to add a project role. - // - // Add a new project role to a project. The key must be unique within the project.\n\nDeprecated: please use user service v2 AddProjectRole. rpc AddProjectRole(AddProjectRoleRequest) returns (AddProjectRoleResponse) { option (google.api.http) = { post: "/projects/{project_id}/roles" @@ -3040,7 +3027,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - deprecated: true; + summary: "Add Project Role"; + description: "Add a new project role to a project. The key must be unique within the project." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3052,11 +3040,6 @@ service ManagementService { }; } - // Bulk add Project Role - // - // Deprecated: [Add Project Role](apis/resources/project_service_v2/project-service-add-project-role.api.mdx) to add a project role. - // - // Add a list of roles to a project. The keys must be unique within the project. rpc BulkAddProjectRoles(BulkAddProjectRolesRequest) returns (BulkAddProjectRolesResponse) { option (google.api.http) = { post: "/projects/{project_id}/roles/_bulk" @@ -3070,7 +3053,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - deprecated: true; + summary: "Bulk Add Project Role"; + description: "Add a list of roles to a project. The keys must be unique within the project." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3082,11 +3066,6 @@ service ManagementService { }; } - // Update Project Role - // - // Deprecated: [Update Project Role](apis/resources/project_service_v2/project-service-update-project-role.api.mdx) to update a project role. - // - // Change a project role. The key is not editable. If a key should change, remove the role and create a new one. rpc UpdateProjectRole(UpdateProjectRoleRequest) returns (UpdateProjectRoleResponse) { option (google.api.http) = { put: "/projects/{project_id}/roles/{role_key}" @@ -3100,7 +3079,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - deprecated: true; + summary: "Change Project Role"; + description: "Change a project role. The key is not editable. If a key should change, remove the role and create a new one." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3112,11 +3092,6 @@ service ManagementService { }; } - // Remove Project Role - // - // Deprecated: [Delete Project Role](apis/resources/project_service_v2/project-service-update-project-role.api.mdx) to remove a project role. - // - // Removes the role from the project and on every resource it has a dependency. This includes project grants and user grants. rpc RemoveProjectRole(RemoveProjectRoleRequest) returns (RemoveProjectRoleResponse) { option (google.api.http) = { delete: "/projects/{project_id}/roles/{role_key}" @@ -3129,7 +3104,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Roles"; - deprecated: true; + summary: "Remove Project Role"; + description: "Removes the role from the project and on every resource it has a dependency. This includes project grants and user grants." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3793,11 +3769,6 @@ service ManagementService { }; } - // Get Project Grant By ID - // - // Deprecated: [List Project Grants](apis/resources/project_service_v2/project-service-list-project-grants.api.mdx) to get a project grant. - // - // Returns a project grant. A project grant is when the organization grants its project to another organization. rpc GetProjectGrantByID(GetProjectGrantByIDRequest) returns (GetProjectGrantByIDResponse) { option (google.api.http) = { get: "/projects/{project_id}/grants/{grant_id}" @@ -3809,7 +3780,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Projects"; - deprecated: true; + summary: "Project Grant By ID"; + description: "Returns a project grant. A project grant is when the organization grants its project to another organization." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3821,11 +3793,6 @@ service ManagementService { }; } - // List Project Grants - // - // Deprecated: [List Project Grants](apis/resources/project_service_v2/project-service-list-project-grants.api.mdx) to list project grants. - // - // Returns a list of project grants for a specific project. A project grant is when the organization grants its project to another organization. rpc ListProjectGrants(ListProjectGrantsRequest) returns (ListProjectGrantsResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants/_search" @@ -3839,7 +3806,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - deprecated: true; + summary: "Search Project Grants from Project"; + description: "Returns a list of project grants for a specific project. A project grant is when the organization grants its project to another organization." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3851,11 +3819,6 @@ service ManagementService { }; } - // Search Project Grants - // - // Deprecated: [List Project Grants](apis/resources/project_service_v2/project-service-list-project-grants.api.mdx) to list project grants. - // - // Returns a list of project grants. A project grant is when the organization grants its project to another organization. rpc ListAllProjectGrants(ListAllProjectGrantsRequest) returns (ListAllProjectGrantsResponse) { option (google.api.http) = { post: "/projectgrants/_search" @@ -3868,7 +3831,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - deprecated: true; + summary: "Search Project Grants"; + description: "Returns a list of project grants. A project grant is when the organization grants its project to another organization." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3880,11 +3844,6 @@ service ManagementService { }; } - // Add Project Grant - // - // Deprecated: [Create Project Grant](apis/resources/project_service_v2/project-service-create-project-grant.api.mdx) to add a project grant. - // - // Grant a project to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization. rpc AddProjectGrant(AddProjectGrantRequest) returns (AddProjectGrantResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants" @@ -3897,7 +3856,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - deprecated: true; + summary: "Add Project Grant"; + description: "Grant a project to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization" parameters: { headers: { name: "x-zitadel-orgid"; @@ -3909,11 +3869,6 @@ service ManagementService { }; } - // Update Project Grant - // - // Deprecated: [Update Project Grant](apis/resources/project_service_v2/project-service-update-project-grant.api.mdx) to update a project grant. - // - // Change the roles of the project that is granted to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization. rpc UpdateProjectGrant(UpdateProjectGrantRequest) returns (UpdateProjectGrantResponse) { option (google.api.http) = { put: "/projects/{project_id}/grants/{grant_id}" @@ -3926,7 +3881,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - deprecated: true; + summary: "Change Project Grant"; + description: "Change the roles of the project that is granted to another organization. The project grant will allow the granted organization to access the project and manage the authorizations for its users. Project Grant will be listed in the granted project of the granted organization" parameters: { headers: { name: "x-zitadel-orgid"; @@ -3938,11 +3894,6 @@ service ManagementService { }; } - // Deactivate Project Grant - // - // Deprecated: [Deactivate Project Grant](apis/resources/project_service_v2/project-service-deactivate-project-grant.api.mdx) to deactivate a project grant. - // - // Set the state of the project grant to deactivated. The grant has to be active to be able to deactivate. rpc DeactivateProjectGrant(DeactivateProjectGrantRequest) returns (DeactivateProjectGrantResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants/{grant_id}/_deactivate" @@ -3955,7 +3906,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - deprecated: true; + summary: "Deactivate Project Grant"; + description: "Set the state of the project grant to deactivated. The grant has to be active to be able to deactivate." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3967,11 +3919,6 @@ service ManagementService { }; } - // Reactivate Project Grant - // - // Deprecated: [Activate Project Grant](apis/resources/project_service_v2/project-service-activate-project-grant.api.mdx) to activate a project grant. - // - // Set the state of the project grant to active. The grant has to be deactivated to be able to reactivate. rpc ReactivateProjectGrant(ReactivateProjectGrantRequest) returns (ReactivateProjectGrantResponse) { option (google.api.http) = { post: "/projects/{project_id}/grants/{grant_id}/_reactivate" @@ -3984,7 +3931,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - deprecated: true; + summary: "Reactivate Project Grant"; + description: "Set the state of the project grant to active. The grant has to be deactivated to be able to reactivate." parameters: { headers: { name: "x-zitadel-orgid"; @@ -3996,11 +3944,6 @@ service ManagementService { }; } - // Remove Project Grant - // - // Deprecated: [Delete Project Grant](apis/resources/project_service_v2/project-service-delete-project-grant.api.mdx) to remove a project grant. - // - // Remove a project grant. All user grants for this project grant will also be removed. A user will not have access to the project afterward (if permissions are checked). rpc RemoveProjectGrant(RemoveProjectGrantRequest) returns (RemoveProjectGrantResponse) { option (google.api.http) = { delete: "/projects/{project_id}/grants/{grant_id}" @@ -4012,7 +3955,8 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Project Grants"; - deprecated: true; + summary: "Remove Project Grant"; + description: "Remove a project grant. All user grants for this project grant will also be removed. A user will not have access to the project afterward (if permissions are checked)." parameters: { headers: { name: "x-zitadel-orgid"; diff --git a/proto/zitadel/metadata/v2beta/metadata.proto b/proto/zitadel/metadata/v2beta/metadata.proto new file mode 100644 index 0000000000..87fcc51869 --- /dev/null +++ b/proto/zitadel/metadata/v2beta/metadata.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +import "zitadel/object/v2beta/object.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +package zitadel.metadata.v2beta; + +option go_package ="github.com/zitadel/zitadel/pkg/grpc/metadata/v2beta"; + +message Metadata { + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + string key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "metadata key", + example: "\"key1\""; + } + ]; + bytes value = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "metadata value is base64 encoded, make sure to decode to get the value", + example: "\"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\""; + } + ]; +} + +message MetadataQuery { + oneof query { + option (validate.required) = true; + MetadataKeyQuery key_query = 1; + } +} + +message MetadataKeyQuery { + string key = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"key\"" + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} diff --git a/proto/zitadel/org/v2beta/org.proto b/proto/zitadel/org/v2beta/org.proto new file mode 100644 index 0000000000..08cf47e820 --- /dev/null +++ b/proto/zitadel/org/v2beta/org.proto @@ -0,0 +1,169 @@ +syntax = "proto3"; + +package zitadel.org.v2beta; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/org/v2beta;org"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/object/v2beta/object.proto"; +import "google/protobuf/timestamp.proto"; + +message Organization { + // Unique identifier of the organization. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + + // The timestamp of the organization was created. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // The timestamp of the verification of the organization domain. + google.protobuf.Timestamp changed_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // Current state of the organization, for example active, inactive and deleted. + OrgState state = 4; + + // Name of the organization. + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + } + ]; + // Primary domain used in the organization. + string primary_domain = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.cloud\""; + } + ]; +} + +enum OrgState { + ORG_STATE_UNSPECIFIED = 0; + ORG_STATE_ACTIVE = 1; + ORG_STATE_INACTIVE = 2; + ORG_STATE_REMOVED = 3; +} + +enum OrgFieldName { + ORG_FIELD_NAME_UNSPECIFIED = 0; + ORG_FIELD_NAME_NAME = 1; + ORG_FIELD_NAME_CREATION_DATE = 2; +} + +message OrganizationSearchFilter{ + oneof filter { + option (validate.required) = true; + + OrgNameFilter name_filter = 1; + OrgDomainFilter domain_filter = 2; + OrgStateFilter state_filter = 3; + OrgIDFilter id_filter = 4; + } +} +message OrgNameFilter { + // Organization name. + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ZITADEL\""; + } + ]; + // Defines which text equality method is used. + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +message OrgDomainFilter { + // The domain. + string domain = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.cloud\""; + } + ]; + // Defines which text equality method is used. + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +message OrgStateFilter { + // Current state of the organization. + OrgState state = 1 [ + (validate.rules).enum.defined_only = true + ]; +} + +message OrgIDFilter { + // The Organization id. + string id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; +} + +// from proto/zitadel/org.proto +message DomainSearchFilter { + oneof filter { + option (validate.required) = true; + DomainNameFilter domain_name_filter = 1; + } +} + +// from proto/zitadel/org.proto +message DomainNameFilter { + // The domain. + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.cloud\""; + } + ]; + // Defines which text equality method is used. + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +// from proto/zitadel/org.proto +message Domain { + // The Organization id. + string organization_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\"" + } + ]; + // The domain name. + string domain_name = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel.com\""; + } + ]; + // Defines if the domain is verified. + bool is_verified = 3; + // Defines if the domain is the primary domain. + bool is_primary = 4; + // Defines the protocol the domain was validated with. + DomainValidationType validation_type = 5; +} + +// from proto/zitadel/org.proto +enum DomainValidationType { + DOMAIN_VALIDATION_TYPE_UNSPECIFIED = 0; + DOMAIN_VALIDATION_TYPE_HTTP = 1; + DOMAIN_VALIDATION_TYPE_DNS = 2; +} diff --git a/proto/zitadel/org/v2beta/org_service.proto b/proto/zitadel/org/v2beta/org_service.proto index e303b676d7..28c823a89b 100644 --- a/proto/zitadel/org/v2beta/org_service.proto +++ b/proto/zitadel/org/v2beta/org_service.proto @@ -6,24 +6,22 @@ package zitadel.org.v2beta; import "zitadel/object/v2beta/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2beta/auth.proto"; -import "zitadel/user/v2beta/email.proto"; -import "zitadel/user/v2beta/phone.proto"; -import "zitadel/user/v2beta/idp.proto"; -import "zitadel/user/v2beta/password.proto"; -import "zitadel/user/v2beta/user.proto"; +import "zitadel/org/v2beta/org.proto"; +import "zitadel/metadata/v2beta/metadata.proto"; import "zitadel/user/v2beta/user_service.proto"; import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; -import "google/protobuf/duration.proto"; import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/filter/v2beta/filter.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/org/v2beta;org"; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { info: { - title: "User Service"; + title: "Organization Service (Beta)"; version: "2.0-beta"; description: "This API is intended to manage organizations in a ZITADEL instance. This project is in beta state. It can AND will continue breaking until the services provide the same functionality as the current login."; contact:{ @@ -111,8 +109,13 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { service OrganizationService { - // Create a new organization and grant the user(s) permission to manage it - rpc AddOrganization(AddOrganizationRequest) returns (AddOrganizationResponse) { + // Create Organization + // + // Create a new organization with an administrative user. If no specific roles are sent for the users, they will be granted the role ORG_OWNER. + // + // Required permission: + // - `org.create` + rpc CreateOrganization(CreateOrganizationRequest) returns (CreateOrganizationResponse) { option (google.api.http) = { post: "/v2beta/organizations" body: "*" @@ -122,34 +125,411 @@ service OrganizationService { auth_option: { permission: "org.create" } - http_response: { - success_code: 201 - } }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create an Organization"; - description: "Create a new organization with an administrative user. If no specific roles are sent for the users, they will be granted the role ORG_OWNER." + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { responses: { - key: "200" + key: "200"; value: { - description: "OK"; + description: "Organization created successfully"; + }; + }; + responses: { + key: "409" + value: { + description: "The organization to create already exists."; } }; }; } + + // Update Organization + // + // Change the name of the organization. + // + // Required permission: + // - `org.write` + rpc UpdateOrganization(UpdateOrganizationRequest) returns (UpdateOrganizationResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Organization created successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "Organisation's not found"; + } + }; + responses: { + key: "409" + value: { + description: "Organisation's name already taken"; + } + }; + }; + + } + + // List Organizations + // + // Returns a list of organizations that match the requesting filters. All filters are applied with an AND condition. + // + // Required permission: + // - `iam.read` + rpc ListOrganizations(ListOrganizationsRequest) returns (ListOrganizationsResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/search"; + body: "*"; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.read"; + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + } + + // Delete Organization + // + // Deletes the organization and all its resources (Users, Projects, Grants to and from the org). Users of this organization will not be able to log in. + // + // Required permission: + // - `org.delete` + rpc DeleteOrganization(DeleteOrganizationRequest) returns (DeleteOrganizationResponse) { + option (google.api.http) = { + delete: "/v2beta/organizations/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.delete"; + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Organization created successfully"; + }; + }; + responses: { + key: "404" + value: { + description: "Organisation's not found"; + } + }; + }; + } + + // Set Organization Metadata + // + // Adds or updates a metadata value for the requested key. Make sure the value is base64 encoded. + // + // Required permission: + // - `org.write` + rpc SetOrganizationMetadata(SetOrganizationMetadataRequest) returns (SetOrganizationMetadataResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{organization_id}/metadata" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + responses: { + // TODO This needs to chagne to 404 + key: "400" + value: { + description: "Organisation's not found"; + } + }; + }; + } + + // List Organization Metadata + // + // List metadata of an organization filtered by query. + // + // Required permission: + // - `org.read` + rpc ListOrganizationMetadata(ListOrganizationMetadataRequest) returns (ListOrganizationMetadataResponse ) { + option (google.api.http) = { + post: "/v2beta/organizations/{organization_id}/metadata/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { + permission: "org.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + + } + + // Delete Organization Metadata + // + // Delete metadata objects from an organization with a specific key. + // + // Required permission: + // - `org.write` + rpc DeleteOrganizationMetadata(DeleteOrganizationMetadataRequest) returns (DeleteOrganizationMetadataResponse) { + option (google.api.http) = { + delete: "/v2beta/organizations/{organization_id}/metadata" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + + } + + // Add Organization Domain + // + // Add a new domain to an organization. The domains are used to identify to which organization a user belongs. + // + // Required permission: + // - `org.write` + rpc AddOrganizationDomain(AddOrganizationDomainRequest) returns (AddOrganizationDomainResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{organization_id}/domains" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + responses: { + key: "409" + value: { + description: "Domain already exists"; + } + }; + }; + + } + + // List Organization Domains + // + // Returns the list of registered domains of an organization. The domains are used to identify to which organization a user belongs. + // + // Required permission: + // - `org.read` + rpc ListOrganizationDomains(ListOrganizationDomainsRequest) returns (ListOrganizationDomainsResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{organization_id}/domains/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + + } + + // Delete Organization Domain + // + // Delete a new domain from an organization. The domains are used to identify to which organization a user belongs. If the uses use the domain for login, this will not be possible afterwards. They have to use another domain instead. + // + // Required permission: + // - `org.write` + rpc DeleteOrganizationDomain(DeleteOrganizationDomainRequest) returns (DeleteOrganizationDomainResponse) { + option (google.api.http) = { + delete: "/v2beta/organizations/{organization_id}/domains" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + + } + + // Generate Organization Domain Validation + // + // Generate a new file to be able to verify your domain with DNS or HTTP challenge. + // + // Required permission: + // - `org.write` + rpc GenerateOrganizationDomainValidation(GenerateOrganizationDomainValidationRequest) returns (GenerateOrganizationDomainValidationResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{organization_id}/domains/validation/generate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + responses: { + key: "404" + value: { + description: "Domain doesn't exist on organization"; + } + }; + }; + } + + // Verify Organization Domain + // + // Make sure you have added the required verification to your domain, depending on the method you have chosen (HTTP or DNS challenge). ZITADEL will check it and set the domain as verified if it was successful. A verify domain has to be unique. + // + // Required permission: + // - `org.write` + rpc VerifyOrganizationDomain(VerifyOrganizationDomainRequest) returns (VerifyOrganizationDomainResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{organization_id}/domains/validation/verify" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + } + + // Deactivate Organization + // + // Sets the state of my organization to deactivated. Users of this organization will not be able to log in. + // + // Required permission: + // - `org.write` + rpc DeactivateOrganization(DeactivateOrganizationRequest) returns (DeactivateOrganizationResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{id}/deactivate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + + } + + // Activate Organization + // + // Set the state of my organization to active. The state of the organization has to be deactivated to perform the request. Users of this organization will be able to log in again. + // + // Required permission: + // - `org.write` + rpc ActivateOrganization(ActivateOrganizationRequest) returns (ActivateOrganizationResponse) { + option (google.api.http) = { + post: "/v2beta/organizations/{id}/activate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "org.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + + } + + } -message AddOrganizationRequest{ +message CreateOrganizationRequest{ + // The Admin for the newly created Organization. message Admin { oneof user_type{ string user_id = 1; zitadel.user.v2beta.AddHumanUserRequest human = 2; } - // specify Org Member Roles for the provided user (default is ORG_OWNER if roles are empty) + // specify Organization Member Roles for the provided user (default is ORG_OWNER if roles are empty) repeated string roles = 3; } + // name of the Organization to be created. string name = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, @@ -159,24 +539,403 @@ message AddOrganizationRequest{ example: "\"ZITADEL\""; } ]; - repeated Admin admins = 2; - // optionally set your own id unique for the organization. - optional string org_id = 3 [ - (validate.rules).string = {min_len: 1, max_len: 200}, + // Optionally set your own id unique for the organization. + optional string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200 }, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; max_length: 200; - example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + example: "\"69629012906488334\""; + } + ]; + // Additional Admins for the Organization. + repeated Admin admins = 3; +} + +message CreatedAdmin { + string user_id = 1; + optional string email_code = 2; + optional string phone_code = 3; +} + +message CreateOrganizationResponse{ + // The timestamp of the organization was created. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // Organization ID of the newly created organization. + string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + + // The admins created for the Organization + repeated CreatedAdmin created_admins = 3; +} + +message UpdateOrganizationRequest { + // Organization Id for the Organization to be updated + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + + // New Name for the Organization to be updated + string name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Customer 1\""; } ]; } -message AddOrganizationResponse{ - message CreatedAdmin { - string user_id = 1; - optional string email_code = 2; - optional string phone_code = 3; - } - zitadel.object.v2beta.Details details = 1; - string organization_id = 2; - repeated CreatedAdmin created_admins = 3; +message UpdateOrganizationResponse { + // The timestamp of the update to the organization. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListOrganizationsRequest { + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // the field the result is sorted + zitadel.org.v2beta.OrgFieldName sorting_column = 2; + // Define the criteria to query for. + // repeated ProjectRoleQuery filters = 4; + repeated zitadel.org.v2beta.OrganizationSearchFilter filter = 3; +} + +message ListOrganizationsResponse { + // Pagination of the Organizations results + zitadel.filter.v2beta.PaginationResponse pagination = 1; + // The Organizations requested + repeated zitadel.org.v2beta.Organization organizations = 2; +} + +message DeleteOrganizationRequest { + + // Organization Id for the Organization to be deleted + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + min_length: 1; + max_length: 200; + } + ]; +} + +message DeleteOrganizationResponse { + // The timestamp of the deletion of the organization. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeactivateOrganizationRequest { + // Organization Id for the Organization to be deactivated + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + min_length: 1; + max_length: 200; + } + ]; +} + +message DeactivateOrganizationResponse { + // The timestamp of the deactivation of the organization. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ActivateOrganizationRequest { + // Organization Id for the Organization to be activated + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + min_length: 1; + max_length: 200; + } + ]; +} + +message ActivateOrganizationResponse { + // The timestamp of the activation of the organization. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message AddOrganizationDomainRequest { + // Organization Id for the Organization for which the domain is to be added to. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + // The domain you want to add to the organization. + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"testdomain.com\""; + } + ]; +} + +message AddOrganizationDomainResponse { + // The timestamp of the organization was created. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ListOrganizationDomainsRequest { + // Organization Id for the Organization which domains are to be listed. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 2; + // Define the criteria to query for. + repeated DomainSearchFilter filters = 3; +} + +message ListOrganizationDomainsResponse { + // Pagination of the Organizations domain results. + zitadel.filter.v2beta.PaginationResponse pagination = 1; + // The domains requested. + repeated Domain domains = 2; +} + +message DeleteOrganizationDomainRequest { + // Organization Id for the Organization which domain is to be deleted. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"testdomain.com\""; + } + ]; +} + +message DeleteOrganizationDomainResponse { + // The timestamp of the deletion of the organization domain. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GenerateOrganizationDomainValidationRequest { + // Organization Id for the Organization which doman to be validated. + string organization_id = 1 [ + + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + // The domain which to be deleted. + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"testdomain.com\""; + } + ]; + DomainValidationType type = 3 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; +} + +message GenerateOrganizationDomainValidationResponse { + // The token verify domain. + string token = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"ofSBHsSAVHAoTIE4Iv2gwhaYhTjcY5QX\""; + } + ]; + // URL used to verify the domain. + string url = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://testdomain.com/.well-known/zitadel-challenge/ofSBHsSAVHAoTIE4Iv2gwhaYhTjcY5QX\""; + } + ]; +} + +message VerifyOrganizationDomainRequest { + // Organization Id for the Organization doman to be verified. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + // Organization Id for the Organization doman to be verified. + string domain = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"testdomain.com\""; + } + ]; +} + +message VerifyOrganizationDomainResponse { + // The timestamp of the verification of the organization domain. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message Metadata { + // Key in the metadata key/value pair. + string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + // Value in the metadata key/value pair. + bytes value = 2 [(validate.rules).bytes = {min_len: 1, max_len: 500000}]; +} +message SetOrganizationMetadataRequest{ + // Organization Id for the Organization doman to be verified. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + // Metadata to set. + repeated Metadata metadata = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + title: "Medata (Key/Value)" + description: "The values have to be base64 encoded."; + example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]" + } + ]; +} + +message SetOrganizationMetadataResponse{ + // The timestamp of the update of the organization metadata. + google.protobuf.Timestamp set_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListOrganizationMetadataRequest { + // Organization ID of Orgalization which metadata is to be listed. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 2; + // Define the criteria to query for. + repeated zitadel.metadata.v2beta.MetadataQuery filter = 3; +} + +message ListOrganizationMetadataResponse { + // Pagination of the Organizations metadata results. + zitadel.filter.v2beta.PaginationResponse pagination = 1; + // The Organization metadata requested. + repeated zitadel.metadata.v2beta.Metadata metadata = 2; +} + +message DeleteOrganizationMetadataRequest { + // Organization ID of Orgalization which metadata is to be deleted is stored on. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + // The keys for the Organization metadata to be deleted. + repeated string keys = 2 [(validate.rules).repeated.items.string = {min_len: 1, max_len: 200}]; +} + +message DeleteOrganizationMetadataResponse{ + // The timestamp of the deletiion of the organization metadata. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; } From 15902f5bc79047dd1bf6083e60047a4acccad353 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 3 Jun 2025 14:48:15 +0200 Subject: [PATCH 079/123] fix(cache): prevent org cache overwrite by other instances (#10012) # Which Problems Are Solved A customer reported that randomly certain login flows, such as automatic redirect to the only configured IdP would not work. During the investigation it was discovered that they used that same primary domain on two different instances. As they used the domain for preselecting the organization, one would always overwrite the other in the cache. Since The organization and especially it's policies could not be retrieved on the other instance, it would fallback to the default organization settings, where the external login and the corresponding IdP were not configured. # How the Problems Are Solved Include the instance id in the cache key for organizations to prevent overwrites. # Additional Changes None # Additional Context - found because of a support request - requires backport to 2.70.x, 2.71.x and 3.x --- internal/query/org.go | 22 +++++++++++++++++----- internal/query/org_test.go | 4 ++++ internal/v2/readmodel/org.go | 2 ++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/internal/query/org.go b/internal/query/org.go index dfe90ad9f8..e2d9e205da 100644 --- a/internal/query/org.go +++ b/internal/query/org.go @@ -12,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" domain_pkg "github.com/zitadel/zitadel/internal/domain" + es "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/feature" "github.com/zitadel/zitadel/internal/query/projection" @@ -77,6 +78,8 @@ type Org struct { ResourceOwner string State domain_pkg.OrgState Sequence uint64 + // instanceID is used to create a unique cache key for the org + instanceID string Name string Domain string @@ -122,7 +125,7 @@ func (q *Queries) OrgByID(ctx context.Context, shouldTriggerBulk bool, id string ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if org, ok := q.caches.org.Get(ctx, orgIndexByID, id); ok { + if org, ok := q.caches.org.Get(ctx, orgIndexByID, orgCacheKey(authz.GetInstance(ctx).InstanceID(), id)); ok { return org, nil } defer func() { @@ -159,6 +162,7 @@ func (q *Queries) OrgByID(ctx context.Context, shouldTriggerBulk bool, id string ResourceOwner: foundOrg.Owner, State: domain_pkg.OrgState(foundOrg.State.State), Sequence: uint64(foundOrg.Sequence), + instanceID: foundOrg.InstanceID, Name: foundOrg.Name, Domain: foundOrg.PrimaryDomain.Domain, }, nil @@ -195,7 +199,7 @@ func (q *Queries) OrgByPrimaryDomain(ctx context.Context, domain string) (org *O ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - org, ok := q.caches.org.Get(ctx, orgIndexByPrimaryDomain, domain) + org, ok := q.caches.org.Get(ctx, orgIndexByPrimaryDomain, orgCacheKey(authz.GetInstance(ctx).InstanceID(), domain)) if ok { return org, nil } @@ -430,6 +434,7 @@ func prepareOrgQuery() (sq.SelectBuilder, func(*sql.Row) (*Org, error)) { OrgColumnResourceOwner.identifier(), OrgColumnState.identifier(), OrgColumnSequence.identifier(), + OrgColumnInstanceID.identifier(), OrgColumnName.identifier(), OrgColumnDomain.identifier(), ). @@ -444,6 +449,7 @@ func prepareOrgQuery() (sq.SelectBuilder, func(*sql.Row) (*Org, error)) { &o.ResourceOwner, &o.State, &o.Sequence, + &o.instanceID, &o.Name, &o.Domain, ) @@ -521,15 +527,21 @@ const ( func (o *Org) Keys(index orgIndex) []string { switch index { case orgIndexByID: - return []string{o.ID} + return []string{orgCacheKey(o.instanceID, o.ID)} case orgIndexByPrimaryDomain: - return []string{o.Domain} + return []string{orgCacheKey(o.instanceID, o.Domain)} case orgIndexUnspecified: } return nil } +func orgCacheKey(instanceID, key string) string { + return instanceID + "-" + key +} + func (c *Caches) registerOrgInvalidation() { - invalidate := cacheInvalidationFunc(c.org, orgIndexByID, getAggregateID) + invalidate := cacheInvalidationFunc(c.org, orgIndexByID, func(aggregate *es.Aggregate) string { + return orgCacheKey(aggregate.InstanceID, aggregate.ID) + }) projection.OrgProjection.RegisterCacheInvalidation(invalidate) } diff --git a/internal/query/org_test.go b/internal/query/org_test.go index d704d2901a..635594e7fd 100644 --- a/internal/query/org_test.go +++ b/internal/query/org_test.go @@ -50,6 +50,7 @@ var ( ` projections.orgs1.resource_owner,` + ` projections.orgs1.org_state,` + ` projections.orgs1.sequence,` + + ` projections.orgs1.instance_id,` + ` projections.orgs1.name,` + ` projections.orgs1.primary_domain` + ` FROM projections.orgs1` @@ -60,6 +61,7 @@ var ( "resource_owner", "org_state", "sequence", + "instance_id", "name", "primary_domain", } @@ -242,6 +244,7 @@ func Test_OrgPrepares(t *testing.T) { "ro", domain.OrgStateActive, uint64(20211108), + "instance-id", "org-name", "zitadel.ch", }, @@ -254,6 +257,7 @@ func Test_OrgPrepares(t *testing.T) { ResourceOwner: "ro", State: domain.OrgStateActive, Sequence: 20211108, + instanceID: "instance-id", Name: "org-name", Domain: "zitadel.ch", }, diff --git a/internal/v2/readmodel/org.go b/internal/v2/readmodel/org.go index 94bcb21537..ce61ef69b0 100644 --- a/internal/v2/readmodel/org.go +++ b/internal/v2/readmodel/org.go @@ -18,6 +18,7 @@ type Org struct { CreationDate time.Time ChangeDate time.Time Owner string + InstanceID string } func NewOrg(id string) *Org { @@ -60,6 +61,7 @@ func (rm *Org) Reduce(events ...*eventstore.StorageEvent) error { } rm.Sequence = event.Sequence rm.ChangeDate = event.CreatedAt + rm.InstanceID = event.Aggregate.Instance } if err := rm.State.Reduce(events...); err != nil { return err From b8ff83454e8cf08f8969db94266c44a0ee158b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Tue, 3 Jun 2025 15:44:04 +0200 Subject: [PATCH 080/123] docs: product roadmap and zitadel versions (#9838) # Which Problems Are Solved The current public roadmap can be hard to understand for customers and it doesn't show the timelines for the different versions. which results in a lot of requests. It only outlines what is already fixed on the timeline, but doesn't give any possibilities to outline future topics / features, which not yet have a timeline # How the Problems Are Solved A new roadmap page is added - Outline for each version when it will have which state - Outline different zitadel versions with its features, deprecations, breaking changes, etc. - Show future topics, which are not yet on the roadmap --- docs/docs/apis/introduction.mdx | 2 +- docs/docs/concepts/architecture/software.md | 2 +- docs/docs/guides/integrate/actions/usage.md | 2 +- .../guides/integrate/login-ui/device-auth.mdx | 4 +- .../integrate/login-ui/oidc-standard.mdx | 2 +- .../integrate/login-ui/saml-standard.mdx | 2 +- .../guides/integrate/login/login-users.mdx | 4 +- .../guides/integrate/login/oidc/webkeys.md | 2 +- docs/docs/guides/manage/customize/branding.md | 2 +- docs/docs/product/_beta-ga.mdx | 1 + docs/docs/product/_breaking-changes.mdx | 1 + docs/docs/product/_deprecated.mdx | 1 + docs/docs/product/_new-feature.mdx | 1 + docs/docs/product/_sdk_v3.mdx | 32 + docs/docs/product/release-cycle.mdx | 63 ++ docs/docs/product/roadmap.mdx | 714 ++++++++++++++++++ docs/sidebars.js | 15 +- docs/src/css/custom.css | 4 + docs/static/img/product/release-cycle.png | Bin 0 -> 102080 bytes 19 files changed, 842 insertions(+), 12 deletions(-) create mode 100644 docs/docs/product/_beta-ga.mdx create mode 100644 docs/docs/product/_breaking-changes.mdx create mode 100644 docs/docs/product/_deprecated.mdx create mode 100644 docs/docs/product/_new-feature.mdx create mode 100644 docs/docs/product/_sdk_v3.mdx create mode 100644 docs/docs/product/release-cycle.mdx create mode 100644 docs/docs/product/roadmap.mdx create mode 100644 docs/static/img/product/release-cycle.png diff --git a/docs/docs/apis/introduction.mdx b/docs/docs/apis/introduction.mdx index e05a7e84b3..905adfc0fb 100644 --- a/docs/docs/apis/introduction.mdx +++ b/docs/docs/apis/introduction.mdx @@ -45,7 +45,7 @@ The [OIDC Playground](https://zitadel.com/playgrounds/oidc) is for testing OpenI ### Custom -ZITADEL allows to authenticate users by creating a session with the [Session API](/docs/apis/resources/session_service_v2), get OIDC authentication request details with the [OIDC service API](/docs/apis/resources/oidc_service) or get SAML request details with the [SAML service API](/docs/apis/resources/saml_service). +ZITADEL allows to authenticate users by creating a session with the [Session API](/docs/apis/resources/session_service_v2), get OIDC authentication request details with the [OIDC service API](/docs/apis/resources/oidc_service_v2) or get SAML request details with the [SAML service API](/docs/apis/resources/saml_service_v2). User authorizations can be [retrieved as roles from our APIs](/docs/guides/integrate/retrieve-user-roles). Refer to our guide to learn how to [build your own login UI](/docs/guides/integrate/login-ui) diff --git a/docs/docs/concepts/architecture/software.md b/docs/docs/concepts/architecture/software.md index dc6f2b56c7..01bacfe12d 100644 --- a/docs/docs/concepts/architecture/software.md +++ b/docs/docs/concepts/architecture/software.md @@ -147,5 +147,5 @@ Zitadel currently supports PostgreSQL. Make sure to read our [Production Guide](/docs/self-hosting/manage/production#prefer-postgresql) before you decide on using one of them. :::info -Zitadel v2 supported CockroachDB and PostgreSQL. Zitadel v3 only supports PostgreSQL. Please refer to [the mirror guide](cli/mirror) to migrate to PostgreSQL. +Zitadel v2 supported CockroachDB and PostgreSQL. Zitadel v3 only supports PostgreSQL. Please refer to [the mirror guide](/docs/self-hosting/manage/cli/mirror) to migrate to PostgreSQL. ::: \ No newline at end of file diff --git a/docs/docs/guides/integrate/actions/usage.md b/docs/docs/guides/integrate/actions/usage.md index ba512ae549..e21fb4935d 100644 --- a/docs/docs/guides/integrate/actions/usage.md +++ b/docs/docs/guides/integrate/actions/usage.md @@ -371,7 +371,7 @@ The API documentation to create a target can be found [here](/apis/resources/act To ensure the integrity of request content, each call includes a 'ZITADEL-Signature' in the headers. This header contains an HMAC value computed from the request content and a timestamp, which can be used to time out requests. The logic for this process is provided in 'pkg/actions/signing.go'. The goal is to verify that the HMAC value in the header matches the HMAC value computed by the Target, ensuring that the sent and received requests are identical. Each Target resource now contains also a Signing Key, which gets generated and returned when a Target is [created](/apis/resources/action_service_v2/action-service-create-target), -and can also be newly generated when a Target is [patched](/apis/resources/action_service_v2/action-service-patch-target). +and can also be newly generated when a Target is [patched](/apis/resources/action_service_v2/action-service-update-target). For an example on how to check the signature, [refer to the example](/guides/integrate/actions/testing-request-signature). diff --git a/docs/docs/guides/integrate/login-ui/device-auth.mdx b/docs/docs/guides/integrate/login-ui/device-auth.mdx index f60fad1310..32984a17ff 100644 --- a/docs/docs/guides/integrate/login-ui/device-auth.mdx +++ b/docs/docs/guides/integrate/login-ui/device-auth.mdx @@ -102,7 +102,7 @@ Present the user with the information of the device authorization request and al ### Perform Login After you have initialized the OIDC flow you can implement the login. -Implement all the steps you like the user the go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service/session-service-set-session) the user-session. +Implement all the steps you like the user the go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service_v2/session-service-set-session) the user-session. Read the following resources for more information about the different checks: - [Username and Password](./username-password) @@ -117,7 +117,7 @@ On the create and update user session request you will always get a session toke The latest session token has to be sent to the following request: -Read more about the [Authorize or Deny Device Authorization Request Documentation](/docs/apis/resources/oidc_service_v2/oidc-service-authorize-device-authorization) +Read more about the [Authorize or Deny Device Authorization Request Documentation](/docs/apis/resources/oidc_service_v2/oidc-service-authorize-or-deny-device-authorization) Make sure that the authorization header is from an account which is permitted to finalize the Auth Request through the `IAM_LOGIN_CLIENT` role. ```bash diff --git a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx index c96338fbf0..92068e5116 100644 --- a/docs/docs/guides/integrate/login-ui/oidc-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/oidc-standard.mdx @@ -80,7 +80,7 @@ Response Example: ### Perform Login After you have initialized the OIDC flow you can implement the login. -Implement all the steps you like the user the go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service/session-service-set-session) the user-session. +Implement all the steps you like the user the go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service_v2/session-service-set-session) the user-session. Read the following resources for more information about the different checks: - [Username and Password](./username-password) diff --git a/docs/docs/guides/integrate/login-ui/saml-standard.mdx b/docs/docs/guides/integrate/login-ui/saml-standard.mdx index 8114350d5d..5196f6c81a 100644 --- a/docs/docs/guides/integrate/login-ui/saml-standard.mdx +++ b/docs/docs/guides/integrate/login-ui/saml-standard.mdx @@ -77,7 +77,7 @@ Response Example: ### Perform Login After you have initialized the SAML flow you can implement the login. -Implement all the steps you like the user to go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service/session-service-set-session) the user-session. +Implement all the steps you like the user to go trough by [creating](/docs/apis/resources/session_service_v2/session-service-create-session) and [updating](/docs/apis/resources/session_service_v2/session-service-set-session) the user-session. Read the following resources for more information about the different checks: - [Username and Password](./username-password) diff --git a/docs/docs/guides/integrate/login/login-users.mdx b/docs/docs/guides/integrate/login/login-users.mdx index de1dacfe24..13439ac1db 100644 --- a/docs/docs/guides/integrate/login/login-users.mdx +++ b/docs/docs/guides/integrate/login/login-users.mdx @@ -25,7 +25,7 @@ The identity provider is not part of the original application, but a standalone The user will authenticate using their credentials. After successful authentication, the user will be redirected back to the original application. -If you want to read more about authenticating with OIDC, head over to our comprehensive [OpenID Connect Guide](/docs/integrate/login/oidc). +If you want to read more about authenticating with OIDC, head over to our comprehensive [OpenID Connect Guide](/docs/guides/integrate/login/oidc). ### Authenticate users with SAML @@ -54,7 +54,7 @@ Note that SAML might not be suitable for mobile applications. In case you want to integrate a mobile application, use OpenID Connect or our Session API. There are more [differences between SAML and OIDC](https://zitadel.com/blog/saml-vs-oidc) that you might want to consider. -If you want to read more about authenticating with SAML, head over to our comprehensive [SAML Guide](/docs/integrate/login/saml). +If you want to read more about authenticating with SAML, head over to our comprehensive [SAML Guide](/docs/guides/integrate/login/saml). ## ZITADEL's Session API diff --git a/docs/docs/guides/integrate/login/oidc/webkeys.md b/docs/docs/guides/integrate/login/oidc/webkeys.md index a66cae61a9..62f62a90e0 100644 --- a/docs/docs/guides/integrate/login/oidc/webkeys.md +++ b/docs/docs/guides/integrate/login/oidc/webkeys.md @@ -85,7 +85,7 @@ The same counts for [zitadel/oidc](https://github.com/zitadel/oidc) Go library. ## Web Key management -ZITADEL provides a resource based [web keys API](/docs/apis/resources/webkey_service_v3). +ZITADEL provides a resource based [web keys API](/docs/apis/resources/webkey_service_v2). The API allows the creation, activation, deletion and listing of web keys. All public keys that are stored for an instance are served on the [JWKS endpoint](#json-web-key-set). Applications need public keys for token verification and not all applications are capable of on-demand diff --git a/docs/docs/guides/manage/customize/branding.md b/docs/docs/guides/manage/customize/branding.md index 14c18705f6..c5ec0a8838 100644 --- a/docs/docs/guides/manage/customize/branding.md +++ b/docs/docs/guides/manage/customize/branding.md @@ -46,7 +46,7 @@ If you like to trigger your settings for your applications you have different po Send a [reserved scope](/apis/openidoauth/scopes) with your [authorization request](../../integrate/login/oidc/login-users#auth-request) to trigger your organization. The primary domain scope will restrict the login to your organization, so only users of your own organization will be able to login. -You can use our [OpenID Authentication Request Playground](/oidc-playground) to learn more about how to trigger an [organization's policies and branding](/oidc-playground#organization-policies-and-branding). +You can use our [OpenID Authentication Request Playground](https://zitadel.com/playgrounds/oidc) to learn more about how to trigger an [organization's policies and branding](https://zitadel.com/playgrounds/oidc#organization-policies-and-branding). ### 2. Setting on your Project diff --git a/docs/docs/product/_beta-ga.mdx b/docs/docs/product/_beta-ga.mdx new file mode 100644 index 0000000000..229f94cdf5 --- /dev/null +++ b/docs/docs/product/_beta-ga.mdx @@ -0,0 +1 @@ +This describes the progression of features from a limited, pre-release testing phase (Beta) to their official, stable, and publicly available version (General Availability), ready for widespread use, with the specific transitions listed below. \ No newline at end of file diff --git a/docs/docs/product/_breaking-changes.mdx b/docs/docs/product/_breaking-changes.mdx new file mode 100644 index 0000000000..dd903a0f2e --- /dev/null +++ b/docs/docs/product/_breaking-changes.mdx @@ -0,0 +1 @@ +These are modifications to existing functionalities that may require users to alter their current implementation or usage to ensure continued compatibility; see the list below for specifics. \ No newline at end of file diff --git a/docs/docs/product/_deprecated.mdx b/docs/docs/product/_deprecated.mdx new file mode 100644 index 0000000000..e5848d68f0 --- /dev/null +++ b/docs/docs/product/_deprecated.mdx @@ -0,0 +1 @@ +This announces that specific existing features are being phased out and are scheduled for future removal, often because they have become outdated or are being replaced by an improved alternative; please see the deprecated items listed below. \ No newline at end of file diff --git a/docs/docs/product/_new-feature.mdx b/docs/docs/product/_new-feature.mdx new file mode 100644 index 0000000000..1877a1d690 --- /dev/null +++ b/docs/docs/product/_new-feature.mdx @@ -0,0 +1 @@ +These introduce brand-new functionalities or capabilities, expanding the product's offerings and value to users, as detailed below. \ No newline at end of file diff --git a/docs/docs/product/_sdk_v3.mdx b/docs/docs/product/_sdk_v3.mdx new file mode 100644 index 0000000000..76040640a2 --- /dev/null +++ b/docs/docs/product/_sdk_v3.mdx @@ -0,0 +1,32 @@ +import NewFeature from './_new-feature.mdx'; + +An initial version of our Software Development Kit (SDK) will be published. +To better align our versioning with the [ZITADEL core](#zitadel-core), the SDK will be released as version 3.x. +This strategic versioning will ensure a more consistent and intuitive development experience across our entire ecosystem. + +
+ New Features + + + +
+ Machine User Authentication Methods + + This feature introduces robust and standardized authentication methods for your machine users, enabling secure automated access to your resources. + + Choose from the following authentication methods: + - **Private Key JWT Authentication**: Enhance security by using asymmetric cryptography. A client with a registered public key can generate and sign a JSON Web Token (JWT) with its private key to authenticate. + - **Client Credentials Grant**: A simple and direct method for machine-to-machine authentication where the client confidentially provides its credentials to the authorization server in exchange for an access token. + - **Personal Access Tokens (PATs)**: Ideal for individual developers or specific scripts, PATs offer a convenient way to create long-lived, revocable tokens with specific scopes, acting as a substitute for a password. + +
+ +
+ Zitadel APIs Wrapper + + This SDK provides a convenient client for interacting with the ZITADEL APIs, simplifying how you manage resources within your instance. + + Currently, the client is tailored for machine-to-machine communication, enabling machine users to authenticate and manage ZITADEL resources programmatically. + Please note that this initial version is focused on API calls for automated tasks and does not yet include support for human user authentication flows like OAuth or OIDC. +
+
diff --git a/docs/docs/product/release-cycle.mdx b/docs/docs/product/release-cycle.mdx new file mode 100644 index 0000000000..f38c81a36d --- /dev/null +++ b/docs/docs/product/release-cycle.mdx @@ -0,0 +1,63 @@ +--- +title: Release Cycle +sidebar_label: Release Cycle +--- + +We release a new major version of our software every three months. This predictable schedule allows us to introduce significant features and enhancements in a structured way. + +This cadence provides enough time for thorough development and rigorous testing. Before each stable release, we engage with our community and customers to test and stabilize the new version. This ensures high quality and reliability. For our customers, this approach creates a clear and manageable upgrade path. + +While major changes are reserved for these three-month releases, we address urgent needs by backporting smaller updates, such as critical bug and security fixes, to earlier versions. This allows us to provide essential updates without altering the predictable rhythm of our major release cycle. + + +![Release Cycle](/img/product/release-cycle.png) + +## Preparation + +The first quarter of our cycle is for Preparation and Planning, where we create the blueprint for the upcoming major release. +During this time, we define the core architecture, map out the implementation strategy, and finalize the design for the new features. + + +## Implementation + +The second month is the Implementation and Development Phase, where our engineers build the features defined in the planning stage. + +During this period, we focus on writing the code for the new enhancements. +We also integrate accepted contributions from our community and create the necessary documentation alongside the development work. +This phase concludes when the new version is feature-complete and ready to enter the testing phase. + +## Release Candidate (RC) + +The first month of the third quarter is for the Release Candidate (RC) and Stabilization Phase. +At the beginning of this month, we publish a Release Candidate version. +This is a feature-complete version that we believe is ready for public release, made available to our customers and community for widespread testing. + +This phase is critical for ensuring the quality of the final release. We have two main objectives: +- **Community Feedback and Bug Fixing**: This is when we rely on your feedback. By testing the RC in your own environments, you help us find and fix bugs and other issues we may have missed. Your active participation is crucial for stabilizing the new version. +- **Enhanced Internal Testing**: While the community provides feedback, our internal teams conduct enhanced quality assurance. This includes in-depth feature validation, rigorous testing of upgrade paths from previous versions, and comprehensive performance and benchmark testing. + +The goal of this phase is to use both community feedback and internal testing to ensure the new release is robust, bug-free, and performs well, so our customers can upgrade with confidence. + +## General Availability (GA) / Stable + +Following the month-long Release Candidate and Stabilization phase, we publish the official General Availability (GA) / Stable Release. +This is the final, production-ready version of our software that has been thoroughly tested by both our internal teams and the community. + +This release is available to everyone, and we recommend that customers begin reviewing the official upgrade path for their production environments. +The deployment of this new major version to our cloud services also happens at this time. + +**Ongoing Maintenance: Minor and Patch Releases** +Once a major version becomes stable, we provide ongoing support through back-porting. This means we carefully select and apply critical updates from our main development track to the stable release, ensuring it remains secure and reliable. These updates are delivered in two ways: + +- Minor Releases: These include simple features and enhancements from the next release cycle that are safe to add, requiring no major refactoring or large database migrations. +- Patch Releases: These are focused exclusively on high-priority bug and security fixes to address critical issues promptly. + +This process ensures that you can benefit from the stability of a major release while still receiving important updates and fixes in a timely manner. + +## Deprecated + +Each major version is actively supported for a full release cycle after its launch. This means that approximately six months after its initial stable release, a version enters its deprecation period. + +Once a version is deprecated, we strongly encourage all self-hosted customers to upgrade to a newer version as soon as possible to continue receiving the latest features, improvements, and bug fixes. + +For our enterprise customers, we may offer extended support by providing critical security fixes for a deprecated version beyond the standard six-month lifecycle. This extended support is evaluated on a case-by-case basis to ensure a secure and manageable transition for large-scale deployments. \ No newline at end of file diff --git a/docs/docs/product/roadmap.mdx b/docs/docs/product/roadmap.mdx new file mode 100644 index 0000000000..b83efedb10 --- /dev/null +++ b/docs/docs/product/roadmap.mdx @@ -0,0 +1,714 @@ +--- +title: Zitadel Release Versions and Roadmap +sidebar_label: Release Versions and Roadmap +--- + + +import NewFeature from './_new-feature.mdx'; +import BreakingChanges from './_breaking-changes.mdx'; +import Deprecated from './_deprecated.mdx'; +import BetaToGA from './_beta-ga.mdx'; +import SDKv3 from './_sdk_v3.mdx'; + + +## Timeline and Overview + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
20252026
Q1Q2Q3Q4Q1Q2Q3Q4
JanFebMarAprMayJunJulAugSepOctNovDecJanFebMarAprMayJunJulAugSepOctNovDec
Zitadel Versions
[v2.x](/docs/product/roadmap#v2x)GA / Stable Deprecated
[v3.x](/docs/product/roadmap#v3x)ImplementationRCGA / Stable Deprecated
[v4.x](/docs/product/roadmap#v4x)ImplementationRCGA / Stable Deprecated
[v5.x](/docs/product/roadmap#v5x)ImplementationRCGA / Stable Deprecated
+ +For more detailed description about the different stages and the release cycle check out the following Page: [Release Cycle](/docs/product/release-cycle) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
25-Q125-Q225-Q325-Q4
Zitadel Core
+ [v2.x](/docs/product/roadmap#v2x) + + [v3.x](/docs/product/roadmap#v3x) +
    +
  • + Actions V2 +
  • +
  • + Removed CockroachDB Support +
  • +
  • + License Change +
  • +
  • + Login v2 +
      +
    • + Initial Release +
    • +
    • + All standard authentication methods +
    • +
    • + OIDC & SAML +
    • +
    +
  • +
+
+ [v4.x](/docs/product/roadmap#v4x) +
    +
  • Resource API
  • +
  • + Login v2 as default + +
      +
    • + Device Authorization Flow +
    • +
    • + LDAP IDP +
    • +
    • + JWT IDP +
    • +
    • + Custom Login UI Texts +
    • +
    +
  • +
+ +
+ [v5.x](/docs/product/roadmap#v5x) +
    +
  • Analytics
  • +
  • User Groups
  • +
  • User Uniqueness on Instance Level
  • +
  • Remove Required Fields from User
  • +
+
Zitadel SDKs
+ + + + +
    +
  • + [Initial Version of PHP SDK](/docs/product/roadmap#v3x-1) +
  • +
  • + [Initial Version of Java SDK](/docs/product/roadmap#v3x-2) +
  • +
  • + [Initial Version of Ruby SDK](/docs/product/roadmap#v3x-3) +
  • +
  • + [Initial Version of Python SDK](/docs/product/roadmap#v3x-4) +
  • +
+
+
+ +## Zitadel Core + +Check out all [Zitadel Release Versions](https://github.com/zitadel/zitadel/releases) + +### v2.x + +**Current State**: General Availability / Stable + +**Release**: [v2.x](https://github.com/zitadel/zitadel/releases?q=v2.&expanded=true) + +In Zitadel versions 2.x and earlier, new releases were deployed with a minimum frequency of every two weeks. +This practice resulted in a significant number of individual versions. +To review the features and bug fixes for these releases, please consult the linked release information provided above. + +### v3.x + +ZITADEL v3 is here, bringing key changes designed to empower your identity management experience. +This release transitions our licensing to AGPLv3, reinforcing our commitment to open and collaborative development. +We've streamlined our database support by removing CockroachDB. +Excitingly, v3 introduces the foundational elements for Actions V2, opening up a world of possibilities for tailoring and extending ZITADEL to perfectly fit your unique use cases. + +**Current State**: General Availability / Stable + +**Release**: [v3.x](https://github.com/zitadel/zitadel/releases?q=v3.&expanded=true) + +**Blog**: [Zitadel v3: AGPL License, Streamlined Releases, and Platform Updates](https://zitadel.com/blog/zitadel-v3-announcement) + + +
+ New Features + + + +
+ Actions V2 + + Zitadel Actions V2 empowers you to customize Zitadel's workflows by executing your own logic at specific points. You define external Endpoints containing your code and configure Targets and Executions within Zitadel to trigger them based on various conditions and events. + + Why we built it: To provide greater flexibility and control, allowing you to implement custom business rules, automate tasks, enrich user data, control access, and integrate with other systems seamlessly. Actions V2 enables you to tailor Zitadel precisely to your unique needs. + + Read more in our [documentation](https://zitadel.com/docs/concepts/features/actions_v2) +
+ +
+ License Change Apache 2.0 to AGPL3 + + Zitadel is switching to the AGPL 3.0 license to ensure the project's sustainability and encourage community contributions from commercial users, while keeping the core free and open source. + + Read more about our [decision](https://zitadel.com/blog/apache-to-agpl) +
+
+ +
+ Breaking Changes + + + +
+ CockroachDB Support removed + + After careful consideration, we have made the decision to discontinue support for CockroachDB in Zitadel v3 and beyond. + While CockroachDB is an excellent distributed SQL database, supporting multiple database backends has increased our maintenance burden and complicated our testing matrix. + Check out our [migration guide](https://zitadel.com/docs/self-hosting/manage/cli/mirror) to migrate from CockroachDB to PostgreSQL. + + More details can be found [here](https://github.com/zitadel/zitadel/issues/9414) +
+ +
+ Actions API v3 alpha removed + + With the current release we have published the Actions V2 API as a beta version, and got rid of the previously published alpha API. + Check out the [new API](http://localhost:3000/docs/apis/resources/action_service_v2) + +
+
+ +### v4.x + +**Current State**: Implementation + + +
+ New Features + + + +
+ Resource API (v2) + + We are revamping our APIs to improve the developer experience. + Currently, our use-case-based APIs are complex and inconsistent, causing confusion and slowing down integration. + To fix this, we're shifting to a resource-based approach. + This means developers will use consistent endpoints (e.g., /users) to manage resources, regardless of their own role. + This change, along with standardized naming and improved documentation, will simplify integration, accelerate development, and create a more intuitive experience for our customers and community. + + Resources integrated in this release: + - Instances + - Organizations + - Projects + - Users + + For more details read the [Github Issue](https://github.com/zitadel/zitadel/issues/6305) +
+ +
+ Login V2 + + Our new login UI has been enhanced with additional features, bringing it to feature parity with Version 1. + +
+ Device Authorization Flow + + The Device Authorization Grant is an OAuth 2.0 flow designed for devices that have limited input capabilities (like smart TVs, gaming consoles, or IoT devices) or lack a browser. + + Read our docs about how to integrate your application using the [Device Authorization Flow](https://zitadel.com/docs/guides/integrate/login/oidc/device-authorization) +
+ +
+ LDAP IDP + + This feature enables users to log in using their existing LDAP (Lightweight Directory Access Protocol) credentials. + It integrates your system with an LDAP directory, allowing it to act as an Identity Provider (IdP) solely for authentication purposes. + This means users can securely access the service with their familiar LDAP username and password, streamlining the login process. +
+ +
+ JWT IDP + + This "JSON Web Token Identity Provider (JWT IdP)" feature allows you to use an existing JSON Web Token (JWT) from another system (like a Web Application Firewall managing a session) as a federated identity for authentication in new applications managed by ZITADEL. + + Essentially, it enables session reuse by letting ZITADEL trust and validate a JWT issued by an external source. This allows users already authenticated in an existing system to seamlessly access new applications without re-logging in. + + Read more in our docs about how to login users with [JWT IDP](https://zitadel.com/docs/guides/integrate/identity-providers/jwt_idp) +
+ +
+ Custom Login UI Texts + + This feature provides customers with the flexibility to personalize the user experience by customizing various text elements across different screens of the login UI. Administrators can modify default messages, labels, and instructions to align with their branding, provide specific guidance, or cater to unique regional or organizational needs, ensuring a more tailored and intuitive authentication process for their users. +
+
+
+ + +
+ General Availability + + + +
+ Hosted Login v2 + + We're officially moving our new Login UI v2 from beta to General Availability. + Starting now, it will be the default login experience for all new customers. + With this release, 8.0we are also focused on implementing previously missing features, such as device authorization and LDAP IDP support, to make the new UI fully feature-complete. + + - [Hosted Login V2](http://localhost:3000/docs/guides/integrate/login/hosted-login#hosted-login-version-2-beta) +
+ +
+ Web Keys + + Web Keys in ZITADEL are used to sign and verify JSON Web Tokens (JWT). + ID tokens are created, signed and returned by ZITADEL when a OpenID connect (OIDC) or OAuth2 authorization flow completes and a user is authenticated. + Based on customer and community feedback, we've updated our key management system. You now have full manual control over key generation and rotation, instead of the previous automatic process. + + Read the full description about Web Keys in our [Documentation](https://zitadel.com/docs/guides/integrate/login/oidc/webkeys). +
+ +
+ SCIM 2.0 Server - User Resource + + The Zitadel SCIM v2 service provider interface enables seamless integration of identity and access management (IAM) systems with Zitadel, following the System for Cross-domain Identity Management (SCIM) v2.0 specification. + This interface allows standardized management of IAM resources, making it easier to automate user provisioning and deprovisioning. + + - [SCIM 2.0 API](https://zitadel.com/docs/apis/scim2) + - [Manage Users Guide](https://zitadel.com/docs/guides/manage/user/scim2) +
+ + +
+ Token Exchange (Impersonation) + + The Token Exchange grant implements [RFC 8693, OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) and can be used to exchange tokens to a different scope, audience or subject. + Changing the subject of an authenticated token is called impersonation or delegation. + Read more in our [Impersonation and delegation using Token Exchange](https://zitadel.com/docs/guides/integrate/token-exchange) Guide +
+ +
+ Caches + + ZITADEL supports the use of a caches to speed up the lookup of frequently needed objects. + As opposed to HTTP caches which might reside between ZITADEL and end-user applications, the cache build into ZITADEL uses active invalidation when an object gets updated. + Another difference is that HTTP caches only cache the result of a complete request and the built-in cache stores objects needed for the internal business logic. + For example, each request made to ZITADEL needs to retrieve and set instance information in middleware. + + Read more about Zitadel Caches [here](https://zitadel.com/docs/self-hosting/manage/cache) +
+
+ +### v5.x + +**Current State**: Planning + +
+ New Features + + + +
+ Analytics + + We provide comprehensive and insightful analytics capabilities that empower you with the information needed to understand platform usage, monitor system health, and make data-driven decisions. + +
+ Daily Active Users (DAU) & Monthly Active Users (MAU) + + Administrators need to track user activity to understand platform usage and identify trends. + This feature provides basic metrics for daily and monthly active users, allowing for filtering by date range and scope (instance-wide or within a specific organization). + The metrics should ensure that each user is counted only once per day or month, respectively, regardless of how many actions they performed. + This minimal feature serves as a foundation for future expansion into more detailed analytics. + + For more details track our [github issue](https://github.com/zitadel/zitadel/issues/7506). +
+ +
+ Resource Count Metrics + + To effectively manage a Zitadel instance, administrators need to understand resource utilization. + This feature provides metrics for resource counts, including organizations, users (with filtering options), projects, applications, and authorizations. + For users, we will offer filters to retrieve the total count, counts per organization, and counts by user type (human or machine). + These metrics will provide administrators with valuable insights into the scale and complexity of their Zitadel instance. + + + For more details track our [github issue](https://github.com/zitadel/zitadel/issues/9709). +
+ +
+ Operational Metrics + + To empower customers to better manage and optimize their Zitadel instances, we will provide access to detailed operational metrics. + This data will help customers identify potential issues, optimize performance, and ensure the stability of their deployments. + The provided data will encompass basic system information, infrastructure details, configuration settings, error reports, and the health status of various Zitadel components, accessible via a user interface or an API. + + + For more details track our [github issue](https://github.com/zitadel/zitadel/issues/9476). + +
+
+ +
+ User Groups + + Administrators will be able to define groups within an organization and assign users to these groups. + More details about the feature can be found [here](https://github.com/zitadel/zitadel/issues/9702) +
+ +
+ User Uniqueness on Organization Level + + Administrators will be able to define weather users should be unique across the instance or within an organization. + This allows managing users independently and avoids conflicts due to shared user identifiers. + Example: The user with the username user@gmail.com can be created in the Organization "Customer A" and "Customer B" if uniqueness is defined on the organization level. + + Stay updated on the progress and details on our [GitHub Issue](https://github.com/zitadel/zitadel/issues/9535) +
+ + +
+ Remove Required Fields + + Currently, the user creation process requires several fields, such as email, first name, and last name, which can be restrictive in certain scenarios. This feature allows administrators to create users with only a username, making other fields optional. + This provides flexibility for systems that don't require complete user profiles upon initial creation for example simplified onboarding flows. + + For more details check out our [GitHub Issue](https://github.com/zitadel/zitadel/issues/4386) +
+
+ +
+ Feature Deprecation + + + +
+ Actions V1 +
+
+ +
+ Breaking Changes + + + +
+ Hosted Login v1 will be removed +
+ + +
+ Zitadel APIs v1 will be removed +
+
+ + +### v6.x + +
+ New Features + + + +
+ Basic Threat Detection Framework + + This initial version of our Threat Detection Framework is designed to enhance the security of your account by identifying and challenging potentially anomalous user behavior. + When the system detects unusual activity, it will present a challenge, such as a reCAPTCHA, to verify that the user is legitimate and not a bot or malicious actor. + Security administrators will also have the ability to revoke user sessions based on the output of the threat detection model, providing a crucial tool to mitigate potential security risks in real-time. + + We are beginning with a straightforward reCAPTCHA-style challenge to build and refine the core framework. + This foundational step will allow us to gather insights into how the system performs and how it can be improved. + Future iterations will build upon this groundwork to incorporate more sophisticated detection methods and a wider range of challenge and response mechanisms, ensuring an increasingly robust and intelligent security posture for all users. + + More details can be found in the (GitHub Issue](https://github.com/zitadel/zitadel/issues/9707) +
+ +
+ SCIM Outbound + + Automate user provisioning to your external applications with our new SCIM Client. + This feature ensures users are automatically created in downstream systems before their first SSO login, preventing access issues and streamlining onboarding. + + It also synchronizes user lifecycle events, so changes like deactivations or deletions are instantly reflected across all connected applications for consistent and secure access management. + The initial release will focus on provisioning the user resource. + + More details can be found in the (GitHub Issue](https://github.com/zitadel/zitadel/issues/6601) +
+ +
+ Analytics + + We provide comprehensive and insightful analytics capabilities that empower you with the information needed to understand platform usage, monitor system health, and make data-driven decisions. + +
+ Login Insights: Successful and Failed Login Metrics + + To enhance security monitoring and gain insights into user authentication patterns, administrators need access to login metrics. + This feature provides data on successful and failed login attempts, allowing for filtering by time range and level (overall instance, within a specific organization, or for a particular application). + This will enable administrators to detect suspicious login activity, analyze authentication trends, and proactively address potential security concerns. + + For more details track our [GitHub issue](https://github.com/zitadel/zitadel/issues/9711). +
+
+ +
+ Impersonation: External Token Exchange + + This feature expands our existing impersonation capabilities to support seamless and secure integration with external, third-party applications. + Currently, our platform supports impersonation for internal use cases, allowing administrators or support staff to obtain a temporary token for an end-user to troubleshoot issues or provide assistance within applications that already use ZITADEL for authentication. (You can find more details in our [existing documentation](/docs/guides/integrate/token-exchange)). + + The next evolution of this feature will focus on external applications. + This enables scenarios where a user, already authenticated in a third-party system (like their primary e-banking portal), can seamlessly access a connected application that is secured by ZITADEL without needing to log in again. + + For example, a user in their e-banking app could click to open an integrated "Budget Planning" tool that relies on ZITADEL for access. + Using a secure token exchange, the budget app will grant the user a valid session on their behalf, creating a smooth, uninterrupted user experience while maintaining a high level of security. + This enhancement bridges the authentication gap between external platforms and ZITADEL-powered applications. +
+
+ +### Future Vision / Upcoming Features + +#### Fine Grained Authorization + +We're planning the future of Zitadel and fine-grained authorization is high on our list. +While Zitadel already offers strong role-based access (RBAC), we know many of you need more granular control. + +**What is Fine-Grained Authorization?** + +It's about moving beyond broad roles to define precise access based on: + +- Attributes (ABAC): User details (department, location), resource characteristics (sensitivity), or context (time of day). +- Relationships (ReBAC): Connections between users and resources (e.g., "owner" of a document, "manager" of a team). +- Policies (PBAC): Explicit rules combining attributes and relationships. + +**Why Explore This?** + +Fine-grained authorization can offer: +- Tighter Security: Minimize access to only what's essential. +- Greater Flexibility: Adapt to complex and dynamic business rules. +- Easier Compliance: Meet strict regulatory demands. +- Scalable Permissions: Manage access effectively as you grow. + +**We Need Your Input!** 🗣️ + +As we explore the best way to bring this to Zitadel, tell us: +- Your Use Cases: Where do you need more detailed access control than standard roles provide? +- Preferred Models: Are you thinking attribute-based, relationship-based, or something else? +- Integration Preferences: + - A fully integrated solution within Zitadel? + - Or integration with existing authorization vendors (e.g. openFGA, cerbos, etc.)? + +Your feedback is crucial for shaping our roadmap. + +🔗 Share your thoughts and needs in our [discussion forum](https://discord.com/channels/927474939156643850/1368861057669533736) + +#### Threat Detection + +We're taking the next step in securing your applications by exploring a new Threat Detection framework for Zitadel. +Our goal is to proactively identify and stop malicious activity in real-time. + +**Our First Step: A Modern reCAPTCHA Alternative** +We will begin by building a system to detect and mitigate malicious bots, serving as a smart, privacy-focused alternative to CAPTCHA. +This initial use case will help us combat credential stuffing, spam registrations, and other automated attacks, forming the foundation of our larger framework. + +**How We Envision It** + +Our exploration is focused on creating an intelligent system that: +- **Analyzes Signals**: Gathers data points like IP reputation, device characteristics, and user behavior to spot suspicious activity. +- **Uses AI/**: Trains models to distinguish between legitimate users and bots, reducing friction for real users. +- **Mitigates Threats**: Enables flexible responses when a threat is detected, such as blocking the attempt, requiring MFA, or sending an alert. + +**Help Us Shape the Future** 🤝 + +As we design this framework, we need to know: +- What are your biggest security threats today? +- What kind of automated responses (e.g., block, notification) would be most useful for you? +- What are your key privacy or compliance concerns regarding threat detection? + +Your feedback will directly influence our development and ensure we build a solution that truly meets your needs. + +🔗 Join the conversation and share your insights [here](https://discord.com/channels/927474939156643850/1375383775164235806) + + +#### The Role of AI in Zitadel + +As we look to the future, we believe Artificial Intelligence will be a critical tool for enhancing both user experience and security within Zitadel. +Our vision for AI is focused on two key areas: providing intelligent, contextual assistance and building a collective defense against emerging threats. + +1. **AI-Powered Support** + + We want you to get fast, accurate answers to your questions without ever having to leave your workflow. + To achieve this, we are integrating an AI-powered support assistant trained on our knowledge base, including our documentation, tutorials, and community discussions. + + Our rollout is planned in phases to ensure we deliver a helpful experience: + - **Phase 1 (Happening Now)**: We are currently testing a preliminary version of our AI bot within our [community channels](https://discord.com/channels/927474939156643850/1357076488825995477). This allows us to gather real-world questions and answers, refining the AI's accuracy and helpfulness based on direct feedback. + - **Phase 2 (Next Steps)**: Once we are confident in its capabilities, we will integrate this AI assistant directly into our documentation. You'll be able to ask complex questions and get immediate, well-sourced answers. + - **Phase 3 (The Ultimate Goal)**: The final step is to embed the assistant directly into the Zitadel Console/Customer Portal. Imagine getting help based on the exact context of what you're doing—whether you're configuring an action, setting up a new organization, or integrating social login. + +2. **Decentralized AI for Threat Detection** + + Security threats are constantly evolving. + A threat vector that targets one customer today might target another tomorrow. + We believe in the power of collective intelligence to provide proactive security for everyone. + + This leads to our second major AI initiative: **decentralized model training** for our Threat Detection framework. + + Here’s how it would work: + - **Collective Data, Anonymously**: Customers across our cloud and self-hosted environments experience different user behaviors and threat vectors. We plan to offer an opt-in system where anonymized, non-sensitive data (like behavioral patterns and threat signals) can be collected from participating instances. + - **Centralized Training**: This collective, anonymized data will be used to train powerful, next-generation AI security models. With a much larger and more diverse dataset, these models can learn to identify subtle and emerging threats far more effectively than a model trained on a single instance's data. + - **Shared Protection**: These constantly improving models would then be distributed to all participating Zitadel instances. + + The result is a powerful security network effect. You could receive protection from a threat vector you haven't even experienced yet, simply because the system learned from an attack on another member of the community. + + + +## Zitadel Ecosystem + +### PHP SDK + +GitHub Repository: [PHP SDK](https://github.com/zitadel/client-php) + +#### v3.x + + + +### Java SDK + +GitHub Repository: [Java SDK](https://github.com/zitadel/client-java) + +#### v3.x + + + +### Ruby SDK + +GitHub Repository: [Ruby SDK](https://github.com/zitadel/client-ruby) + +#### v3.x + + + +### Python SDK + +GitHub Repository: [Python SDK](https://github.com/zitadel/client-python) + +#### v3.x + + diff --git a/docs/sidebars.js b/docs/sidebars.js index f9b97703e5..1bd53ed1b3 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -183,7 +183,6 @@ module.exports = { items: [ "guides/manage/user/reg-create-user", "guides/manage/customize/user-metadata", - "guides/manage/customize/user-schema", "guides/manage/user/scim2", ], }, @@ -611,6 +610,20 @@ module.exports = { }, ], }, + { + type: "category", + label: "Product Information", + collapsed: true, + items: [ + "product/roadmap", + "product/release-cycle", + { + type: "link", + label: "Changelog", + href: "https://zitadel.com/changelog", + }, + ], + }, { type: "category", label: "Support", diff --git a/docs/src/css/custom.css b/docs/src/css/custom.css index 50035f2541..11d4208695 100644 --- a/docs/src/css/custom.css +++ b/docs/src/css/custom.css @@ -641,3 +641,7 @@ p strong { .zitadel-lifecycle-deprecated { text-decoration: line-through; } + +table#zitadel-versions td { + vertical-align: top; +} \ No newline at end of file diff --git a/docs/static/img/product/release-cycle.png b/docs/static/img/product/release-cycle.png new file mode 100644 index 0000000000000000000000000000000000000000..7017566ab7087d14af651d9e94f4b99caecac804 GIT binary patch literal 102080 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 literal 0 HcmV?d00001 From b9c1cdf4adb8b4a7737b8f8d5b6a5abd270ad5e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 3 Jun 2025 17:15:30 +0300 Subject: [PATCH 081/123] feat(projections): resource counters (#9979) # Which Problems Are Solved Add the ability to keep track of the current counts of projection resources. We want to prevent calling `SELECT COUNT(*)` on tables, as that forces a full scan and sudden spikes of DB resource uses. # How the Problems Are Solved - A resource_counts table is added - Triggers that increment and decrement the counted values on inserts and deletes - Triggers that delete all counts of a table when the source table is TRUNCATEd. This is not in the business logic, but prevents wrong counts in case someone want to force a re-projection. - Triggers that delete all counts if the parent resource is deleted - Script to pre-populate the resource_counts table when a new source table is added. The triggers are reusable for any type of resource, in case we choose to add more in the future. Counts are aggregated by a given parent. Currently only `instance` and `organization` are defined as possible parent. This can later be extended to other types, such as `project`, should the need arise. I deliberately chose to use `parent_id` to distinguish from the de-factor `resource_owner` which is usually an organization ID. For example: - For users the parent is an organization and the `parent_id` matches `resource_owner`. - For organizations the parent is an instance, but the `resource_owner` is the `org_id`. In this case the `parent_id` is the `instance_id`. - Applications would have a similar problem, where the parent is a project, but the `resource_owner` is the `org_id` # Additional Context Closes https://github.com/zitadel/zitadel/issues/9957 --- cmd/setup/57.go | 27 ++ cmd/setup/57.sql | 106 ++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 3 + cmd/setup/trigger_steps.go | 125 +++++++++ internal/domain/count_trigger.go | 9 + internal/domain/countparenttype_enumer.go | 109 ++++++++ internal/domain/secretgeneratortype_enumer.go | 93 +++++-- internal/migration/count_trigger.sql | 43 +++ .../delete_parent_counts_trigger.sql | 13 + internal/migration/migration.go | 5 +- internal/migration/trigger.go | 127 +++++++++ internal/migration/trigger_test.go | 253 ++++++++++++++++++ internal/query/resource_counts.go | 61 +++++ internal/query/resource_counts_list.sql | 12 + internal/query/resource_counts_test.go | 109 ++++++++ 16 files changed, 1080 insertions(+), 16 deletions(-) create mode 100644 cmd/setup/57.go create mode 100644 cmd/setup/57.sql create mode 100644 cmd/setup/trigger_steps.go create mode 100644 internal/domain/count_trigger.go create mode 100644 internal/domain/countparenttype_enumer.go create mode 100644 internal/migration/count_trigger.sql create mode 100644 internal/migration/delete_parent_counts_trigger.sql create mode 100644 internal/migration/trigger.go create mode 100644 internal/migration/trigger_test.go create mode 100644 internal/query/resource_counts.go create mode 100644 internal/query/resource_counts_list.sql create mode 100644 internal/query/resource_counts_test.go diff --git a/cmd/setup/57.go b/cmd/setup/57.go new file mode 100644 index 0000000000..4c52018f1e --- /dev/null +++ b/cmd/setup/57.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 57.sql + createResourceCounts string +) + +type CreateResourceCounts struct { + dbClient *database.DB +} + +func (mig *CreateResourceCounts) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, createResourceCounts) + return err +} + +func (mig *CreateResourceCounts) String() string { + return "57_create_resource_counts" +} diff --git a/cmd/setup/57.sql b/cmd/setup/57.sql new file mode 100644 index 0000000000..f2f0a40202 --- /dev/null +++ b/cmd/setup/57.sql @@ -0,0 +1,106 @@ +CREATE TABLE IF NOT EXISTS projections.resource_counts +( + id SERIAL PRIMARY KEY, -- allows for easy pagination + instance_id TEXT NOT NULL, + table_name TEXT NOT NULL, -- needed for trigger matching, not in reports + parent_type TEXT NOT NULL, + parent_id TEXT NOT NULL, + resource_name TEXT NOT NULL, -- friendly name for reporting + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + amount INTEGER NOT NULL DEFAULT 1 CHECK (amount >= 0), + + UNIQUE (instance_id, parent_type, parent_id, table_name) +); + +-- count_resource is a trigger function which increases or decreases the count of a resource. +-- When creating the trigger the following required arguments (TG_ARGV) can be passed: +-- 1. The type of the parent +-- 2. The column name of the instance id +-- 3. The column name of the owner id +-- 4. The name of the resource +CREATE OR REPLACE FUNCTION projections.count_resource() + RETURNS trigger + LANGUAGE 'plpgsql' VOLATILE +AS $$ +DECLARE + -- trigger variables + tg_table_name TEXT := TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME; + tg_parent_type TEXT := TG_ARGV[0]; + tg_instance_id_column TEXT := TG_ARGV[1]; + tg_parent_id_column TEXT := TG_ARGV[2]; + tg_resource_name TEXT := TG_ARGV[3]; + + tg_instance_id TEXT; + tg_parent_id TEXT; + + select_ids TEXT := format('SELECT ($1).%I, ($1).%I', tg_instance_id_column, tg_parent_id_column); +BEGIN + IF (TG_OP = 'INSERT') THEN + EXECUTE select_ids INTO tg_instance_id, tg_parent_id USING NEW; + + INSERT INTO projections.resource_counts(instance_id, table_name, parent_type, parent_id, resource_name) + VALUES (tg_instance_id, tg_table_name, tg_parent_type, tg_parent_id, tg_resource_name) + ON CONFLICT (instance_id, table_name, parent_type, parent_id) DO + UPDATE SET updated_at = now(), amount = projections.resource_counts.amount + 1; + + RETURN NEW; + ELSEIF (TG_OP = 'DELETE') THEN + EXECUTE select_ids INTO tg_instance_id, tg_parent_id USING OLD; + + UPDATE projections.resource_counts + SET updated_at = now(), amount = amount - 1 + WHERE instance_id = tg_instance_id + AND table_name = tg_table_name + AND parent_type = tg_parent_type + AND parent_id = tg_parent_id + AND resource_name = tg_resource_name + AND amount > 0; -- prevent check failure on negative amount. + + RETURN OLD; + END IF; +END +$$; + +-- delete_table_counts removes all resource counts for a TRUNCATED table. +CREATE OR REPLACE FUNCTION projections.delete_table_counts() + RETURNS trigger + LANGUAGE 'plpgsql' +AS $$ +DECLARE + -- trigger variables + tg_table_name TEXT := TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME; +BEGIN + DELETE FROM projections.resource_counts + WHERE table_name = tg_table_name; +END +$$; + +-- delete_parent_counts removes all resource counts for a deleted parent. +-- 1. The type of the parent +-- 2. The column name of the instance id +-- 3. The column name of the owner id +CREATE OR REPLACE FUNCTION projections.delete_parent_counts() + RETURNS trigger + LANGUAGE 'plpgsql' +AS $$ +DECLARE + -- trigger variables + tg_parent_type TEXT := TG_ARGV[0]; + tg_instance_id_column TEXT := TG_ARGV[1]; + tg_parent_id_column TEXT := TG_ARGV[2]; + + tg_instance_id TEXT; + tg_parent_id TEXT; + + select_ids TEXT := format('SELECT ($1).%I, ($1).%I', tg_instance_id_column, tg_parent_id_column); +BEGIN + EXECUTE select_ids INTO tg_instance_id, tg_parent_id USING OLD; + + DELETE FROM projections.resource_counts + WHERE instance_id = tg_instance_id + AND parent_type = tg_parent_type + AND parent_id = tg_parent_id; + + RETURN OLD; +END +$$; diff --git a/cmd/setup/config.go b/cmd/setup/config.go index bd2abde9ea..dd59ba3f07 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -153,6 +153,7 @@ type Steps struct { s54InstancePositionIndex *InstancePositionIndex s55ExecutionHandlerStart *ExecutionHandlerStart s56IDPTemplate6SAMLFederatedLogout *IDPTemplate6SAMLFederatedLogout + s57CreateResourceCounts *CreateResourceCounts } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index c84976f282..1465180a6b 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -215,6 +215,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s54InstancePositionIndex = &InstancePositionIndex{dbClient: dbClient} steps.s55ExecutionHandlerStart = &ExecutionHandlerStart{dbClient: dbClient} steps.s56IDPTemplate6SAMLFederatedLogout = &IDPTemplate6SAMLFederatedLogout{dbClient: dbClient} + steps.s57CreateResourceCounts = &CreateResourceCounts{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -260,6 +261,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s54InstancePositionIndex, steps.s55ExecutionHandlerStart, steps.s56IDPTemplate6SAMLFederatedLogout, + steps.s57CreateResourceCounts, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { @@ -296,6 +298,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) client: dbClient, }, } + repeatableSteps = append(repeatableSteps, triggerSteps(dbClient)...) for _, repeatableStep := range repeatableSteps { setupErr = executeMigration(ctx, eventstoreClient, repeatableStep, "unable to migrate repeatable step") diff --git a/cmd/setup/trigger_steps.go b/cmd/setup/trigger_steps.go new file mode 100644 index 0000000000..163a8fdb59 --- /dev/null +++ b/cmd/setup/trigger_steps.go @@ -0,0 +1,125 @@ +package setup + +import ( + "fmt" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/migration" + "github.com/zitadel/zitadel/internal/query/projection" +) + +// triggerSteps defines the repeatable migrations that set up triggers +// for counting resources in the database. +func triggerSteps(db *database.DB) []migration.RepeatableMigration { + return []migration.RepeatableMigration{ + // Delete parent count triggers for instances and organizations + migration.DeleteParentCountsTrigger(db, + projection.InstanceProjectionTable, + domain.CountParentTypeInstance, + projection.InstanceColumnID, + projection.InstanceColumnID, + "instance", + ), + migration.DeleteParentCountsTrigger(db, + projection.OrgProjectionTable, + domain.CountParentTypeOrganization, + projection.OrgColumnInstanceID, + projection.OrgColumnID, + "organization", + ), + + // Count triggers for all the resources + migration.CountTrigger(db, + projection.OrgProjectionTable, + domain.CountParentTypeInstance, + projection.OrgColumnInstanceID, + projection.OrgColumnInstanceID, + "organization", + ), + migration.CountTrigger(db, + projection.ProjectProjectionTable, + domain.CountParentTypeOrganization, + projection.ProjectColumnInstanceID, + projection.ProjectColumnResourceOwner, + "project", + ), + migration.CountTrigger(db, + projection.UserTable, + domain.CountParentTypeOrganization, + projection.UserInstanceIDCol, + projection.UserResourceOwnerCol, + "user", + ), + migration.CountTrigger(db, + projection.InstanceMemberProjectionTable, + domain.CountParentTypeInstance, + projection.MemberInstanceID, + projection.MemberResourceOwner, + "iam_admin", + ), + migration.CountTrigger(db, + projection.IDPTable, + domain.CountParentTypeInstance, + projection.IDPInstanceIDCol, + projection.IDPInstanceIDCol, + "identity_provider", + ), + migration.CountTrigger(db, + projection.IDPTemplateLDAPTable, + domain.CountParentTypeInstance, + projection.LDAPInstanceIDCol, + projection.LDAPInstanceIDCol, + "identity_provider_ldap", + ), + migration.CountTrigger(db, + projection.ActionTable, + domain.CountParentTypeInstance, + projection.ActionInstanceIDCol, + projection.ActionInstanceIDCol, + "action_v1", + ), + migration.CountTrigger(db, + projection.ExecutionTable, + domain.CountParentTypeInstance, + projection.ExecutionInstanceIDCol, + projection.ExecutionInstanceIDCol, + "execution", + ), + migration.CountTrigger(db, + fmt.Sprintf("%s_%s", projection.ExecutionTable, projection.ExecutionTargetSuffix), + domain.CountParentTypeInstance, + projection.ExecutionTargetInstanceIDCol, + projection.ExecutionTargetInstanceIDCol, + "execution_target", + ), + migration.CountTrigger(db, + projection.LoginPolicyTable, + domain.CountParentTypeInstance, + projection.LoginPolicyInstanceIDCol, + projection.LoginPolicyInstanceIDCol, + "login_policy", + ), + migration.CountTrigger(db, + projection.PasswordComplexityTable, + domain.CountParentTypeInstance, + projection.ComplexityPolicyInstanceIDCol, + projection.ComplexityPolicyInstanceIDCol, + "password_complexity_policy", + ), + migration.CountTrigger(db, + projection.PasswordAgeTable, + domain.CountParentTypeInstance, + projection.AgePolicyInstanceIDCol, + projection.AgePolicyInstanceIDCol, + "password_expiry_policy", + ), + migration.CountTrigger(db, + projection.LockoutPolicyTable, + domain.CountParentTypeInstance, + projection.LockoutPolicyInstanceIDCol, + projection.LockoutPolicyInstanceIDCol, + "lockout_policy", + ), + } +} diff --git a/internal/domain/count_trigger.go b/internal/domain/count_trigger.go new file mode 100644 index 0000000000..a29d125fe9 --- /dev/null +++ b/internal/domain/count_trigger.go @@ -0,0 +1,9 @@ +package domain + +//go:generate enumer -type CountParentType -transform lower -trimprefix CountParentType -sql +type CountParentType int + +const ( + CountParentTypeInstance CountParentType = iota + CountParentTypeOrganization +) diff --git a/internal/domain/countparenttype_enumer.go b/internal/domain/countparenttype_enumer.go new file mode 100644 index 0000000000..8691d97e62 --- /dev/null +++ b/internal/domain/countparenttype_enumer.go @@ -0,0 +1,109 @@ +// Code generated by "enumer -type CountParentType -transform lower -trimprefix CountParentType -sql"; DO NOT EDIT. + +package domain + +import ( + "database/sql/driver" + "fmt" + "strings" +) + +const _CountParentTypeName = "instanceorganization" + +var _CountParentTypeIndex = [...]uint8{0, 8, 20} + +const _CountParentTypeLowerName = "instanceorganization" + +func (i CountParentType) String() string { + if i < 0 || i >= CountParentType(len(_CountParentTypeIndex)-1) { + return fmt.Sprintf("CountParentType(%d)", i) + } + return _CountParentTypeName[_CountParentTypeIndex[i]:_CountParentTypeIndex[i+1]] +} + +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _CountParentTypeNoOp() { + var x [1]struct{} + _ = x[CountParentTypeInstance-(0)] + _ = x[CountParentTypeOrganization-(1)] +} + +var _CountParentTypeValues = []CountParentType{CountParentTypeInstance, CountParentTypeOrganization} + +var _CountParentTypeNameToValueMap = map[string]CountParentType{ + _CountParentTypeName[0:8]: CountParentTypeInstance, + _CountParentTypeLowerName[0:8]: CountParentTypeInstance, + _CountParentTypeName[8:20]: CountParentTypeOrganization, + _CountParentTypeLowerName[8:20]: CountParentTypeOrganization, +} + +var _CountParentTypeNames = []string{ + _CountParentTypeName[0:8], + _CountParentTypeName[8:20], +} + +// CountParentTypeString retrieves an enum value from the enum constants string name. +// Throws an error if the param is not part of the enum. +func CountParentTypeString(s string) (CountParentType, error) { + if val, ok := _CountParentTypeNameToValueMap[s]; ok { + return val, nil + } + + if val, ok := _CountParentTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } + return 0, fmt.Errorf("%s does not belong to CountParentType values", s) +} + +// CountParentTypeValues returns all values of the enum +func CountParentTypeValues() []CountParentType { + return _CountParentTypeValues +} + +// CountParentTypeStrings returns a slice of all String values of the enum +func CountParentTypeStrings() []string { + strs := make([]string, len(_CountParentTypeNames)) + copy(strs, _CountParentTypeNames) + return strs +} + +// IsACountParentType returns "true" if the value is listed in the enum definition. "false" otherwise +func (i CountParentType) IsACountParentType() bool { + for _, v := range _CountParentTypeValues { + if i == v { + return true + } + } + return false +} + +func (i CountParentType) Value() (driver.Value, error) { + return i.String(), nil +} + +func (i *CountParentType) Scan(value interface{}) error { + if value == nil { + return nil + } + + var str string + switch v := value.(type) { + case []byte: + str = string(v) + case string: + str = v + case fmt.Stringer: + str = v.String() + default: + return fmt.Errorf("invalid value of CountParentType: %[1]T(%[1]v)", value) + } + + val, err := CountParentTypeString(str) + if err != nil { + return err + } + + *i = val + return nil +} diff --git a/internal/domain/secretgeneratortype_enumer.go b/internal/domain/secretgeneratortype_enumer.go index f819bafc1f..db66715670 100644 --- a/internal/domain/secretgeneratortype_enumer.go +++ b/internal/domain/secretgeneratortype_enumer.go @@ -4,11 +4,14 @@ package domain import ( "fmt" + "strings" ) -const _SecretGeneratorTypeName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailinvite_codesecret_generator_type_count" +const _SecretGeneratorTypeName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailinvite_codesigning_keysecret_generator_type_count" -var _SecretGeneratorTypeIndex = [...]uint8{0, 11, 20, 37, 54, 67, 86, 108, 118, 124, 133, 144, 171} +var _SecretGeneratorTypeIndex = [...]uint8{0, 11, 20, 37, 54, 67, 86, 108, 118, 124, 133, 144, 155, 182} + +const _SecretGeneratorTypeLowerName = "unspecifiedinit_codeverify_email_codeverify_phone_codeverify_domainpassword_reset_codepasswordless_init_codeapp_secretotpsmsotp_emailinvite_codesigning_keysecret_generator_type_count" func (i SecretGeneratorType) String() string { if i < 0 || i >= SecretGeneratorType(len(_SecretGeneratorTypeIndex)-1) { @@ -17,21 +20,70 @@ func (i SecretGeneratorType) String() string { return _SecretGeneratorTypeName[_SecretGeneratorTypeIndex[i]:_SecretGeneratorTypeIndex[i+1]] } -var _SecretGeneratorTypeValues = []SecretGeneratorType{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11} +// An "invalid array index" compiler error signifies that the constant values have changed. +// Re-run the stringer command to generate them again. +func _SecretGeneratorTypeNoOp() { + var x [1]struct{} + _ = x[SecretGeneratorTypeUnspecified-(0)] + _ = x[SecretGeneratorTypeInitCode-(1)] + _ = x[SecretGeneratorTypeVerifyEmailCode-(2)] + _ = x[SecretGeneratorTypeVerifyPhoneCode-(3)] + _ = x[SecretGeneratorTypeVerifyDomain-(4)] + _ = x[SecretGeneratorTypePasswordResetCode-(5)] + _ = x[SecretGeneratorTypePasswordlessInitCode-(6)] + _ = x[SecretGeneratorTypeAppSecret-(7)] + _ = x[SecretGeneratorTypeOTPSMS-(8)] + _ = x[SecretGeneratorTypeOTPEmail-(9)] + _ = x[SecretGeneratorTypeInviteCode-(10)] + _ = x[SecretGeneratorTypeSigningKey-(11)] + _ = x[secretGeneratorTypeCount-(12)] +} + +var _SecretGeneratorTypeValues = []SecretGeneratorType{SecretGeneratorTypeUnspecified, SecretGeneratorTypeInitCode, SecretGeneratorTypeVerifyEmailCode, SecretGeneratorTypeVerifyPhoneCode, SecretGeneratorTypeVerifyDomain, SecretGeneratorTypePasswordResetCode, SecretGeneratorTypePasswordlessInitCode, SecretGeneratorTypeAppSecret, SecretGeneratorTypeOTPSMS, SecretGeneratorTypeOTPEmail, SecretGeneratorTypeInviteCode, SecretGeneratorTypeSigningKey, secretGeneratorTypeCount} var _SecretGeneratorTypeNameToValueMap = map[string]SecretGeneratorType{ - _SecretGeneratorTypeName[0:11]: 0, - _SecretGeneratorTypeName[11:20]: 1, - _SecretGeneratorTypeName[20:37]: 2, - _SecretGeneratorTypeName[37:54]: 3, - _SecretGeneratorTypeName[54:67]: 4, - _SecretGeneratorTypeName[67:86]: 5, - _SecretGeneratorTypeName[86:108]: 6, - _SecretGeneratorTypeName[108:118]: 7, - _SecretGeneratorTypeName[118:124]: 8, - _SecretGeneratorTypeName[124:133]: 9, - _SecretGeneratorTypeName[133:144]: 10, - _SecretGeneratorTypeName[144:171]: 11, + _SecretGeneratorTypeName[0:11]: SecretGeneratorTypeUnspecified, + _SecretGeneratorTypeLowerName[0:11]: SecretGeneratorTypeUnspecified, + _SecretGeneratorTypeName[11:20]: SecretGeneratorTypeInitCode, + _SecretGeneratorTypeLowerName[11:20]: SecretGeneratorTypeInitCode, + _SecretGeneratorTypeName[20:37]: SecretGeneratorTypeVerifyEmailCode, + _SecretGeneratorTypeLowerName[20:37]: SecretGeneratorTypeVerifyEmailCode, + _SecretGeneratorTypeName[37:54]: SecretGeneratorTypeVerifyPhoneCode, + _SecretGeneratorTypeLowerName[37:54]: SecretGeneratorTypeVerifyPhoneCode, + _SecretGeneratorTypeName[54:67]: SecretGeneratorTypeVerifyDomain, + _SecretGeneratorTypeLowerName[54:67]: SecretGeneratorTypeVerifyDomain, + _SecretGeneratorTypeName[67:86]: SecretGeneratorTypePasswordResetCode, + _SecretGeneratorTypeLowerName[67:86]: SecretGeneratorTypePasswordResetCode, + _SecretGeneratorTypeName[86:108]: SecretGeneratorTypePasswordlessInitCode, + _SecretGeneratorTypeLowerName[86:108]: SecretGeneratorTypePasswordlessInitCode, + _SecretGeneratorTypeName[108:118]: SecretGeneratorTypeAppSecret, + _SecretGeneratorTypeLowerName[108:118]: SecretGeneratorTypeAppSecret, + _SecretGeneratorTypeName[118:124]: SecretGeneratorTypeOTPSMS, + _SecretGeneratorTypeLowerName[118:124]: SecretGeneratorTypeOTPSMS, + _SecretGeneratorTypeName[124:133]: SecretGeneratorTypeOTPEmail, + _SecretGeneratorTypeLowerName[124:133]: SecretGeneratorTypeOTPEmail, + _SecretGeneratorTypeName[133:144]: SecretGeneratorTypeInviteCode, + _SecretGeneratorTypeLowerName[133:144]: SecretGeneratorTypeInviteCode, + _SecretGeneratorTypeName[144:155]: SecretGeneratorTypeSigningKey, + _SecretGeneratorTypeLowerName[144:155]: SecretGeneratorTypeSigningKey, + _SecretGeneratorTypeName[155:182]: secretGeneratorTypeCount, + _SecretGeneratorTypeLowerName[155:182]: secretGeneratorTypeCount, +} + +var _SecretGeneratorTypeNames = []string{ + _SecretGeneratorTypeName[0:11], + _SecretGeneratorTypeName[11:20], + _SecretGeneratorTypeName[20:37], + _SecretGeneratorTypeName[37:54], + _SecretGeneratorTypeName[54:67], + _SecretGeneratorTypeName[67:86], + _SecretGeneratorTypeName[86:108], + _SecretGeneratorTypeName[108:118], + _SecretGeneratorTypeName[118:124], + _SecretGeneratorTypeName[124:133], + _SecretGeneratorTypeName[133:144], + _SecretGeneratorTypeName[144:155], + _SecretGeneratorTypeName[155:182], } // SecretGeneratorTypeString retrieves an enum value from the enum constants string name. @@ -40,6 +92,10 @@ func SecretGeneratorTypeString(s string) (SecretGeneratorType, error) { if val, ok := _SecretGeneratorTypeNameToValueMap[s]; ok { return val, nil } + + if val, ok := _SecretGeneratorTypeNameToValueMap[strings.ToLower(s)]; ok { + return val, nil + } return 0, fmt.Errorf("%s does not belong to SecretGeneratorType values", s) } @@ -48,6 +104,13 @@ func SecretGeneratorTypeValues() []SecretGeneratorType { return _SecretGeneratorTypeValues } +// SecretGeneratorTypeStrings returns a slice of all String values of the enum +func SecretGeneratorTypeStrings() []string { + strs := make([]string, len(_SecretGeneratorTypeNames)) + copy(strs, _SecretGeneratorTypeNames) + return strs +} + // IsASecretGeneratorType returns "true" if the value is listed in the enum definition. "false" otherwise func (i SecretGeneratorType) IsASecretGeneratorType() bool { for _, v := range _SecretGeneratorTypeValues { diff --git a/internal/migration/count_trigger.sql b/internal/migration/count_trigger.sql new file mode 100644 index 0000000000..4b521094ab --- /dev/null +++ b/internal/migration/count_trigger.sql @@ -0,0 +1,43 @@ +{{ define "count_trigger" -}} +CREATE OR REPLACE TRIGGER count_{{ .Resource }} + AFTER INSERT OR DELETE + ON {{ .Table }} + FOR EACH ROW + EXECUTE FUNCTION projections.count_resource( + '{{ .ParentType }}', + '{{ .InstanceIDColumn }}', + '{{ .ParentIDColumn }}', + '{{ .Resource }}' + ); + +CREATE OR REPLACE TRIGGER truncate_{{ .Resource }}_counts + AFTER TRUNCATE + ON {{ .Table }} + FOR EACH STATEMENT + EXECUTE FUNCTION projections.delete_table_counts(); + +-- Prevent inserts and deletes while we populate the counts. +LOCK TABLE {{ .Table }} IN SHARE MODE; + +-- Populate the resource counts for the existing data in the table. +INSERT INTO projections.resource_counts( + instance_id, + table_name, + parent_type, + parent_id, + resource_name, + amount +) +SELECT + {{ .InstanceIDColumn }}, + '{{ .Table }}', + '{{ .ParentType }}', + {{ .ParentIDColumn }}, + '{{ .Resource }}', + COUNT(*) AS amount +FROM {{ .Table }} +GROUP BY ({{ .InstanceIDColumn }}, {{ .ParentIDColumn }}) +ON CONFLICT (instance_id, table_name, parent_type, parent_id) DO +UPDATE SET updated_at = now(), amount = EXCLUDED.amount; + +{{- end -}} diff --git a/internal/migration/delete_parent_counts_trigger.sql b/internal/migration/delete_parent_counts_trigger.sql new file mode 100644 index 0000000000..a2e9df6626 --- /dev/null +++ b/internal/migration/delete_parent_counts_trigger.sql @@ -0,0 +1,13 @@ +{{ define "delete_parent_counts_trigger" -}} + +CREATE OR REPLACE TRIGGER delete_parent_counts_trigger + AFTER DELETE + ON {{ .Table }} + FOR EACH ROW + EXECUTE FUNCTION projections.delete_parent_counts( + '{{ .ParentType }}', + '{{ .InstanceIDColumn }}', + '{{ .ParentIDColumn }}' + ); + +{{- end -}} diff --git a/internal/migration/migration.go b/internal/migration/migration.go index a2224340a7..3aeb2f0612 100644 --- a/internal/migration/migration.go +++ b/internal/migration/migration.go @@ -36,7 +36,10 @@ type errCheckerMigration interface { type RepeatableMigration interface { Migration - Check(lastRun map[string]interface{}) bool + + // Check if the migration should be executed again. + // True will repeat the migration, false will not. + Check(lastRun map[string]any) bool } func Migrate(ctx context.Context, es *eventstore.Eventstore, migration Migration) (err error) { diff --git a/internal/migration/trigger.go b/internal/migration/trigger.go new file mode 100644 index 0000000000..bd06afd5c5 --- /dev/null +++ b/internal/migration/trigger.go @@ -0,0 +1,127 @@ +package migration + +import ( + "context" + "embed" + "fmt" + "strings" + "text/template" + + "github.com/mitchellh/mapstructure" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" +) + +const ( + countTriggerTmpl = "count_trigger" + deleteParentCountsTmpl = "delete_parent_counts_trigger" +) + +var ( + //go:embed *.sql + templateFS embed.FS + templates = template.Must(template.ParseFS(templateFS, "*.sql")) +) + +// CountTrigger registers the existing projections.count_trigger function. +// The trigger than takes care of keeping count of existing +// rows in the source table. +// It also pre-populates the projections.resource_counts table with +// the counts for the given table. +// +// During the population of the resource_counts table, +// the source table is share-locked to prevent concurrent modifications. +// Projection handlers will be halted until the lock is released. +// SELECT statements are not blocked by the lock. +// +// This migration repeats when any of the arguments are changed, +// such as renaming of a projection table. +func CountTrigger( + db *database.DB, + table string, + parentType domain.CountParentType, + instanceIDColumn string, + parentIDColumn string, + resource string, +) RepeatableMigration { + return &triggerMigration{ + triggerConfig: triggerConfig{ + Table: table, + ParentType: parentType.String(), + InstanceIDColumn: instanceIDColumn, + ParentIDColumn: parentIDColumn, + Resource: resource, + }, + db: db, + templateName: countTriggerTmpl, + } +} + +// DeleteParentCountsTrigger +// +// This migration repeats when any of the arguments are changed, +// such as renaming of a projection table. +func DeleteParentCountsTrigger( + db *database.DB, + table string, + parentType domain.CountParentType, + instanceIDColumn string, + parentIDColumn string, + resource string, +) RepeatableMigration { + return &triggerMigration{ + triggerConfig: triggerConfig{ + Table: table, + ParentType: parentType.String(), + InstanceIDColumn: instanceIDColumn, + ParentIDColumn: parentIDColumn, + Resource: resource, + }, + db: db, + templateName: deleteParentCountsTmpl, + } +} + +type triggerMigration struct { + triggerConfig + db *database.DB + templateName string +} + +// String implements [Migration] and [fmt.Stringer]. +func (m *triggerMigration) String() string { + return fmt.Sprintf("repeatable_%s_%s", m.Resource, m.templateName) +} + +// Execute implements [Migration] +func (m *triggerMigration) Execute(ctx context.Context, _ eventstore.Event) error { + var query strings.Builder + err := templates.ExecuteTemplate(&query, m.templateName, m.triggerConfig) + if err != nil { + return fmt.Errorf("%s: execute trigger template: %w", m, err) + } + _, err = m.db.ExecContext(ctx, query.String()) + if err != nil { + return fmt.Errorf("%s: exec trigger query: %w", m, err) + } + return nil +} + +type triggerConfig struct { + Table string `json:"table,omitempty" mapstructure:"table"` + ParentType string `json:"parent_type,omitempty" mapstructure:"parent_type"` + InstanceIDColumn string `json:"instance_id_column,omitempty" mapstructure:"instance_id_column"` + ParentIDColumn string `json:"parent_id_column,omitempty" mapstructure:"parent_id_column"` + Resource string `json:"resource,omitempty" mapstructure:"resource"` +} + +// Check implements [RepeatableMigration]. +func (c *triggerConfig) Check(lastRun map[string]any) bool { + var dst triggerConfig + if err := mapstructure.Decode(lastRun, &dst); err != nil { + panic(err) + } + return dst != *c +} diff --git a/internal/migration/trigger_test.go b/internal/migration/trigger_test.go new file mode 100644 index 0000000000..5799526428 --- /dev/null +++ b/internal/migration/trigger_test.go @@ -0,0 +1,253 @@ +package migration + +import ( + "context" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/database" +) + +const ( + expCountTriggerQuery = `CREATE OR REPLACE TRIGGER count_resource + AFTER INSERT OR DELETE + ON table + FOR EACH ROW + EXECUTE FUNCTION projections.count_resource( + 'instance', + 'instance_id', + 'parent_id', + 'resource' + ); + +CREATE OR REPLACE TRIGGER truncate_resource_counts + AFTER TRUNCATE + ON table + FOR EACH STATEMENT + EXECUTE FUNCTION projections.delete_table_counts(); + +-- Prevent inserts and deletes while we populate the counts. +LOCK TABLE table IN SHARE MODE; + +-- Populate the resource counts for the existing data in the table. +INSERT INTO projections.resource_counts( + instance_id, + table_name, + parent_type, + parent_id, + resource_name, + amount +) +SELECT + instance_id, + 'table', + 'instance', + parent_id, + 'resource', + COUNT(*) AS amount +FROM table +GROUP BY (instance_id, parent_id) +ON CONFLICT (instance_id, table_name, parent_type, parent_id) DO +UPDATE SET updated_at = now(), amount = EXCLUDED.amount;` + + expDeleteParentCountsQuery = `CREATE OR REPLACE TRIGGER delete_parent_counts_trigger + AFTER DELETE + ON table + FOR EACH ROW + EXECUTE FUNCTION projections.delete_parent_counts( + 'instance', + 'instance_id', + 'parent_id' + );` +) + +func Test_triggerMigration_Execute(t *testing.T) { + type fields struct { + triggerConfig triggerConfig + templateName string + } + tests := []struct { + name string + fields fields + expects func(sqlmock.Sqlmock) + wantErr bool + }{ + { + name: "template error", + fields: fields{ + triggerConfig: triggerConfig{ + Table: "table", + ParentType: "instance", + InstanceIDColumn: "instance_id", + ParentIDColumn: "parent_id", + Resource: "resource", + }, + templateName: "foo", + }, + expects: func(_ sqlmock.Sqlmock) {}, + wantErr: true, + }, + { + name: "db error", + fields: fields{ + triggerConfig: triggerConfig{ + Table: "table", + ParentType: "instance", + InstanceIDColumn: "instance_id", + ParentIDColumn: "parent_id", + Resource: "resource", + }, + templateName: countTriggerTmpl, + }, + expects: func(mock sqlmock.Sqlmock) { + mock.ExpectExec(regexp.QuoteMeta(expCountTriggerQuery)). + WillReturnError(assert.AnError) + }, + wantErr: true, + }, + { + name: "count trigger", + fields: fields{ + triggerConfig: triggerConfig{ + Table: "table", + ParentType: "instance", + InstanceIDColumn: "instance_id", + ParentIDColumn: "parent_id", + Resource: "resource", + }, + templateName: countTriggerTmpl, + }, + expects: func(mock sqlmock.Sqlmock) { + mock.ExpectExec(regexp.QuoteMeta(expCountTriggerQuery)). + WithoutArgs(). + WillReturnResult( + sqlmock.NewResult(1, 1), + ) + }, + }, + { + name: "count trigger", + fields: fields{ + triggerConfig: triggerConfig{ + Table: "table", + ParentType: "instance", + InstanceIDColumn: "instance_id", + ParentIDColumn: "parent_id", + Resource: "resource", + }, + templateName: deleteParentCountsTmpl, + }, + expects: func(mock sqlmock.Sqlmock) { + mock.ExpectExec(regexp.QuoteMeta(expDeleteParentCountsQuery)). + WithoutArgs(). + WillReturnResult( + sqlmock.NewResult(1, 1), + ) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { + err := mock.ExpectationsWereMet() + require.NoError(t, err) + }() + defer db.Close() + tt.expects(mock) + mock.ExpectClose() + + m := &triggerMigration{ + db: &database.DB{ + DB: db, + }, + triggerConfig: tt.fields.triggerConfig, + templateName: tt.fields.templateName, + } + err = m.Execute(context.Background(), nil) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func Test_triggerConfig_Check(t *testing.T) { + type fields struct { + Table string + ParentType string + InstanceIDColumn string + ParentIDColumn string + Resource string + } + type args struct { + lastRun map[string]any + } + tests := []struct { + name string + fields fields + args args + want bool + }{ + { + name: "should", + fields: fields{ + Table: "users2", + ParentType: "instance", + InstanceIDColumn: "instance_id", + ParentIDColumn: "parent_id", + Resource: "user", + }, + args: args{ + lastRun: map[string]any{ + "table": "users1", + "parent_type": "instance", + "instance_id_column": "instance_id", + "parent_id_column": "parent_id", + "resource": "user", + }, + }, + want: true, + }, + { + name: "should not", + fields: fields{ + Table: "users1", + ParentType: "instance", + InstanceIDColumn: "instance_id", + ParentIDColumn: "parent_id", + Resource: "user", + }, + args: args{ + lastRun: map[string]any{ + "table": "users1", + "parent_type": "instance", + "instance_id_column": "instance_id", + "parent_id_column": "parent_id", + "resource": "user", + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &triggerConfig{ + Table: tt.fields.Table, + ParentType: tt.fields.ParentType, + InstanceIDColumn: tt.fields.InstanceIDColumn, + ParentIDColumn: tt.fields.ParentIDColumn, + Resource: tt.fields.Resource, + } + got := c.Check(tt.args.lastRun) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/query/resource_counts.go b/internal/query/resource_counts.go new file mode 100644 index 0000000000..9d486e0b90 --- /dev/null +++ b/internal/query/resource_counts.go @@ -0,0 +1,61 @@ +package query + +import ( + "context" + "database/sql" + _ "embed" + "time" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var ( + //go:embed resource_counts_list.sql + resourceCountsListQuery string +) + +type ResourceCount struct { + ID int // Primary key, used for pagination + InstanceID string + TableName string + ParentType domain.CountParentType + ParentID string + Resource string + UpdatedAt time.Time + Amount int +} + +// ListResourceCounts retrieves all resource counts. +// It supports pagination using lastID and limit parameters. +// +// TODO: Currently only a proof of concept, filters may be implemented later if required. +func (q *Queries) ListResourceCounts(ctx context.Context, lastID, limit int) (result []ResourceCount, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + for rows.Next() { + var count ResourceCount + err := rows.Scan( + &count.ID, + &count.InstanceID, + &count.TableName, + &count.ParentType, + &count.ParentID, + &count.Resource, + &count.UpdatedAt, + &count.Amount) + if err != nil { + return zerrors.ThrowInternal(err, "QUERY-2f4g5", "Errors.Internal") + } + result = append(result, count) + } + return nil + }, resourceCountsListQuery, lastID, limit) + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-3f4g5", "Errors.Internal") + } + return result, nil +} diff --git a/internal/query/resource_counts_list.sql b/internal/query/resource_counts_list.sql new file mode 100644 index 0000000000..0d4abf87eb --- /dev/null +++ b/internal/query/resource_counts_list.sql @@ -0,0 +1,12 @@ +SELECT id, + instance_id, + table_name, + parent_type, + parent_id, + resource_name, + updated_at, + amount +FROM projections.resource_counts +WHERE id > $1 +ORDER BY id +LIMIT $2; diff --git a/internal/query/resource_counts_test.go b/internal/query/resource_counts_test.go new file mode 100644 index 0000000000..2829a660ef --- /dev/null +++ b/internal/query/resource_counts_test.go @@ -0,0 +1,109 @@ +package query + +import ( + "context" + _ "embed" + "regexp" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" +) + +func TestQueries_ListResourceCounts(t *testing.T) { + columns := []string{"id", "instance_id", "table_name", "parent_type", "parent_id", "resource_name", "updated_at", "amount"} + type args struct { + lastID int + limit int + } + tests := []struct { + name string + args args + expects func(sqlmock.Sqlmock) + wantResult []ResourceCount + wantErr bool + }{ + { + name: "query error", + args: args{ + lastID: 0, + limit: 10, + }, + expects: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery(regexp.QuoteMeta(resourceCountsListQuery)). + WithArgs(0, 10). + WillReturnError(assert.AnError) + }, + wantErr: true, + }, + { + name: "success", + args: args{ + lastID: 0, + limit: 10, + }, + expects: func(mock sqlmock.Sqlmock) { + mock.ExpectQuery(regexp.QuoteMeta(resourceCountsListQuery)). + WithArgs(0, 10). + WillReturnRows( + sqlmock.NewRows(columns). + AddRow(1, "instance_1", "table", "instance", "parent_1", "resource_name", time.Unix(1, 2), 5). + AddRow(2, "instance_2", "table", "instance", "parent_2", "resource_name", time.Unix(1, 2), 6), + ) + }, + wantResult: []ResourceCount{ + { + ID: 1, + InstanceID: "instance_1", + TableName: "table", + ParentType: domain.CountParentTypeInstance, + ParentID: "parent_1", + Resource: "resource_name", + UpdatedAt: time.Unix(1, 2), + Amount: 5, + }, + { + ID: 2, + InstanceID: "instance_2", + TableName: "table", + ParentType: domain.CountParentTypeInstance, + ParentID: "parent_2", + Resource: "resource_name", + UpdatedAt: time.Unix(1, 2), + Amount: 6, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer func() { + err := mock.ExpectationsWereMet() + require.NoError(t, err) + }() + defer db.Close() + tt.expects(mock) + mock.ExpectClose() + q := &Queries{ + client: &database.DB{ + DB: db, + }, + } + + gotResult, err := q.ListResourceCounts(context.Background(), tt.args.lastID, tt.args.limit) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantResult, gotResult, "ListResourceCounts() result mismatch") + }) + } +} From 1e5ffd41c9a624df49d2f743ca199330b9463c91 Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Tue, 3 Jun 2025 17:18:16 +0200 Subject: [PATCH 082/123] docs(10016): improve understanding of output (#10014) # Which Problems Are Solved The output of the sql statement of tech advisory was unclear on how the data should be compared # How the Problems Are Solved An additional column is added to the output to show the effective difference of the old and new position. --- docs/docs/support/advisory/a10016.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/docs/support/advisory/a10016.md b/docs/docs/support/advisory/a10016.md index 38d73e6078..6272eaa81d 100644 --- a/docs/docs/support/advisory/a10016.md +++ b/docs/docs/support/advisory/a10016.md @@ -87,12 +87,13 @@ select b.aggregate_type, b.sequence, b.old_position, - b.new_position + b.new_position, + b.old_position - b.new_position difference from broken b; ``` -If the output from the above looks reasonable, for example not a huge difference between `old_position` and `new_position`, commit the transaction: +If the output from the above looks reasonable, for example not a huge number in the `difference` column, commit the transaction: ```sql commit; From e2a61a60029783f9a29bf7b71f2ac3d8fd39bb78 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 4 Jun 2025 08:41:10 +0200 Subject: [PATCH 083/123] docs(api): remove unreleased services from api reference (#10015) # Which Problems Are Solved As we migrate resources to the new API, whenever a an implementation got merged, the API reference was added to the docs sidenav. As these new services and their implementation are not yet released, it can be confusing for developers as the corresponding endpoints return 404 or unimplemented errors. # How the Problems Are Solved Currently we just remove it from the sidenav and will add it once they're released. We're looking into a proper solution for the API references. # Additional Changes None # Additional Context None --- docs/sidebars.js | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/docs/sidebars.js b/docs/sidebars.js index 1bd53ed1b3..d7ebb80f5b 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -805,18 +805,6 @@ module.exports = { }, items: sidebar_api_org_service_v2, }, - { - type: "category", - label: "Organization (Beta)", - link: { - type: "generated-index", - title: "Organization Service beta API", - slug: "/apis/resources/org_service/v2beta", - description: - "This API is intended to manage organizations for ZITADEL. \n", - }, - items: sidebar_api_org_service_v2beta, - }, { type: "category", label: "Identity Provider", @@ -868,35 +856,6 @@ module.exports = { }, items: sidebar_api_actions_v2, }, - { - type: "category", - label: "Project (Beta)", - link: { - type: "generated-index", - title: "Project Service API (Beta)", - slug: "/apis/resources/project_service_v2", - description: - "This API is intended to manage projects and subresources for ZITADEL. \n"+ - "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.", - }, - items: sidebar_api_project_service_v2, - label: "Instance (Beta)", - link: { - type: "generated-index", - title: "Instance Service API (Beta)", - slug: "/apis/resources/instance_service_v2", - description: - "This API is intended to manage instances, custom domains and trusted domains in ZITADEL.\n" + - "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ - "\n" + - "This v2 of the API provides the same functionalities as the v1, but organised on a per resource basis.\n" + - "The whole functionality related to domains (custom and trusted) has been moved under this instance API." - , - }, - items: sidebar_api_instance_service_v2, - }, ], }, { From 8fc11a7366dcaf24a11d3c4fd26e86f5e61d4d1f Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 4 Jun 2025 09:17:23 +0200 Subject: [PATCH 084/123] feat: user api requests to resource API (#9794) # Which Problems Are Solved This pull request addresses a significant gap in the user service v2 API, which currently lacks methods for managing machine users. # How the Problems Are Solved This PR adds new API endpoints to the user service v2 to manage machine users including their secret, keys and personal access tokens. Additionally, there's now a CreateUser and UpdateUser endpoints which allow to create either a human or machine user and update them. The existing `CreateHumanUser` endpoint has been deprecated along the corresponding management service endpoints. For details check the additional context section. # Additional Context - Closes https://github.com/zitadel/zitadel/issues/9349 ## More details - API changes: https://github.com/zitadel/zitadel/pull/9680 - Implementation: https://github.com/zitadel/zitadel/pull/9763 - Tests: https://github.com/zitadel/zitadel/pull/9771 ## Follow-ups - Metadata: support managing user metadata using resource API https://github.com/zitadel/zitadel/pull/10005 - Machine token type: support managing the machine token type (migrate to new enum with zero value unspecified?) --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Livio Spring --- API_DESIGN.md | 4 +- cmd/start/start.go | 2 +- go.mod | 1 + internal/api/grpc/admin/import.go | 2 +- internal/api/grpc/filter/v2/converter.go | 50 + .../grpc/management/project_application.go | 2 +- internal/api/grpc/management/user.go | 13 +- .../project/v2beta/integration/query_test.go | 148 +- internal/api/grpc/project/v2beta/query.go | 23 +- internal/api/grpc/user/v2/human.go | 187 ++ internal/api/grpc/user/v2/human_test.go | 254 +++ .../user/v2/integration_test/email_test.go | 4 +- .../grpc/user/v2/integration_test/key_test.go | 659 +++++++ .../user/v2/integration_test/password_test.go | 2 +- .../grpc/user/v2/integration_test/pat_test.go | 615 ++++++ .../user/v2/integration_test/phone_test.go | 4 +- .../user/v2/integration_test/secret_test.go | 347 ++++ .../user/v2/integration_test/user_test.go | 1711 ++++++++++++++++- internal/api/grpc/user/v2/key.go | 62 + internal/api/grpc/user/v2/key_query.go | 124 ++ internal/api/grpc/user/v2/machine.go | 58 + internal/api/grpc/user/v2/machine_test.go | 62 + internal/api/grpc/user/v2/pat.go | 56 + internal/api/grpc/user/v2/pat_query.go | 123 ++ internal/api/grpc/user/v2/secret.go | 39 + internal/api/grpc/user/v2/server.go | 16 +- internal/api/grpc/user/v2/user.go | 105 +- .../grpc/user/v2/{query.go => user_query.go} | 0 internal/api/scim/resources/user.go | 1 - internal/command/instance_member.go | 2 +- internal/command/org_member.go | 2 +- ..._ower_model.go => resource_owner_model.go} | 0 internal/command/user.go | 24 +- internal/command/user_machine.go | 48 +- internal/command/user_machine_key.go | 24 +- internal/command/user_machine_model.go | 13 +- internal/command/user_machine_secret.go | 22 +- internal/command/user_machine_secret_test.go | 8 +- internal/command/user_machine_test.go | 238 ++- .../command/user_personal_access_token.go | 20 +- internal/command/user_test.go | 2 +- internal/command/user_v2.go | 2 - internal/command/user_v2_human.go | 64 +- internal/command/user_v2_human_test.go | 8 +- internal/command/user_v2_invite_test.go | 1 + internal/command/user_v2_machine.go | 94 + internal/command/user_v2_machine_test.go | 260 +++ internal/command/user_v2_model.go | 8 + internal/domain/permission.go | 2 +- internal/eventstore/write_model.go | 26 +- internal/integration/client.go | 40 + internal/query/authn_key.go | 146 +- internal/query/authn_key_test.go | 8 + internal/query/projection/authn_key.go | 3 + .../projection/user_personal_access_token.go | 2 + internal/query/user.go | 14 +- internal/query/user_personal_access_token.go | 60 +- internal/repository/user/machine.go | 7 +- internal/static/i18n/bg.yaml | 1 + internal/static/i18n/cs.yaml | 1 + internal/static/i18n/de.yaml | 1 + internal/static/i18n/en.yaml | 1 + internal/static/i18n/es.yaml | 1 + internal/static/i18n/fr.yaml | 1 + internal/static/i18n/hu.yaml | 1 + internal/static/i18n/id.yaml | 1 + internal/static/i18n/it.yaml | 1 + internal/static/i18n/ja.yaml | 1 + internal/static/i18n/ko.yaml | 1 + internal/static/i18n/mk.yaml | 1 + internal/static/i18n/nl.yaml | 1 + internal/static/i18n/pl.yaml | 1 + internal/static/i18n/pt.yaml | 1 + internal/static/i18n/ro.yaml | 1 + internal/static/i18n/ru.yaml | 1 + internal/static/i18n/sv.yaml | 1 + internal/static/i18n/zh.yaml | 1 + proto/buf.yaml | 2 +- proto/zitadel/filter/v2/filter.proto | 96 + proto/zitadel/filter/v2beta/filter.proto | 36 +- proto/zitadel/management.proto | 201 +- proto/zitadel/project/v2beta/query.proto | 66 +- proto/zitadel/user/v2/email.proto | 2 +- proto/zitadel/user/v2/key.proto | 69 + proto/zitadel/user/v2/pat.proto | 70 + proto/zitadel/user/v2/user_service.proto | 1186 +++++++++++- 86 files changed, 7033 insertions(+), 536 deletions(-) create mode 100644 internal/api/grpc/filter/v2/converter.go create mode 100644 internal/api/grpc/user/v2/human.go create mode 100644 internal/api/grpc/user/v2/human_test.go create mode 100644 internal/api/grpc/user/v2/integration_test/key_test.go create mode 100644 internal/api/grpc/user/v2/integration_test/pat_test.go create mode 100644 internal/api/grpc/user/v2/integration_test/secret_test.go create mode 100644 internal/api/grpc/user/v2/key.go create mode 100644 internal/api/grpc/user/v2/key_query.go create mode 100644 internal/api/grpc/user/v2/machine.go create mode 100644 internal/api/grpc/user/v2/machine_test.go create mode 100644 internal/api/grpc/user/v2/pat.go create mode 100644 internal/api/grpc/user/v2/pat_query.go create mode 100644 internal/api/grpc/user/v2/secret.go rename internal/api/grpc/user/v2/{query.go => user_query.go} (100%) rename internal/command/{resource_ower_model.go => resource_owner_model.go} (100%) create mode 100644 internal/command/user_v2_machine.go create mode 100644 internal/command/user_v2_machine_test.go create mode 100644 proto/zitadel/filter/v2/filter.proto create mode 100644 proto/zitadel/user/v2/key.proto create mode 100644 proto/zitadel/user/v2/pat.proto diff --git a/API_DESIGN.md b/API_DESIGN.md index 9e77657ab0..11b7766a49 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -206,6 +206,8 @@ The same applies to messages that are returned by multiple resources. For example, information about the `User` might be different when managing the user resource itself than when it's returned as part of an authorization or a manager role, where only limited information is needed. +On the other hand, types that always follow the same pattern and are used in multiple resources, such as `IDFilter`, `TimestampFilter` or `InIDsFilter` SHOULD be globalized and reused. + ##### Re-using messages Prevent reusing messages for the creation and the retrieval of a resource. @@ -271,7 +273,7 @@ Additionally, state changes, specific actions or operations that do not fit into The API uses OAuth 2 for authorization. There are corresponding middlewares that check the access token for validity and automatically return an error if the token is invalid. -Permissions grated to the user might be organization specific and can therefore only be checked based on the queried resource. +Permissions granted to the user might be organization specific and can therefore only be checked based on the queried resource. In such case, the API does not check the permissions itself but relies on the checks of the functions that are called by the API. If the permission can be checked by the API itself, e.g. if the permission is instance wide, it can be annotated on the endpoint in the proto file (see below). In any case, the required permissions need to be documented in the [API documentation](#documentation). diff --git a/cmd/start/start.go b/cmd/start/start.go index 2fc1fb8413..8820480f0c 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -461,7 +461,7 @@ func startAPIs( if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { + if err := apis.RegisterService(ctx, user_v2.CreateServer(config.SystemDefaults, commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { diff --git a/go.mod b/go.mod index c1cbf2dd77..21a7fe9f16 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/go-webauthn/webauthn v0.10.2 github.com/goccy/go-json v0.10.5 github.com/golang/protobuf v1.5.4 + github.com/google/go-cmp v0.7.0 github.com/gorilla/csrf v1.7.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/schema v1.4.1 diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 119afe9fc0..41a1e39081 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -510,7 +510,7 @@ func importMachineUsers(ctx context.Context, s *Server, errors *[]*admin_pb.Impo } for _, user := range org.GetMachineUsers() { logging.Debugf("import user: %s", user.GetUserId()) - _, err := s.command.AddMachine(ctx, management.AddMachineUserRequestToCommand(user.GetUser(), org.GetOrgId())) + _, err := s.command.AddMachine(ctx, management.AddMachineUserRequestToCommand(user.GetUser(), org.GetOrgId()), nil) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "machine_user", Id: user.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { diff --git a/internal/api/grpc/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go new file mode 100644 index 0000000000..7a7d7cd8d7 --- /dev/null +++ b/internal/api/grpc/filter/v2/converter.go @@ -0,0 +1,50 @@ +package filter + +import ( + "fmt" + + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.TimestampComparison { + switch method { + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS: + return query.TimestampEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE: + return query.TimestampLess + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER: + return query.TimestampGreater + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_BEFORE_OR_EQUALS: + return query.TimestampLessOrEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS: + return query.TimestampGreaterOrEquals + default: + return -1 + } +} + +func PaginationPbToQuery(defaults systemdefaults.SystemDefaults, query *filter.PaginationRequest) (offset, limit uint64, asc bool, err error) { + limit = defaults.DefaultQueryLimit + if query == nil { + return 0, limit, asc, nil + } + offset = query.Offset + asc = query.Asc + if defaults.MaxQueryLimit > 0 && uint64(query.Limit) > defaults.MaxQueryLimit { + return 0, 0, false, zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", query.Limit, defaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded") + } + if query.Limit > 0 { + limit = uint64(query.Limit) + } + return offset, limit, asc, nil +} + +func QueryToPaginationPb(request query.SearchRequest, response query.SearchResponse) *filter.PaginationResponse { + return &filter.PaginationResponse{ + AppliedLimit: request.Limit, + TotalResult: response.Count, + } +} diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index 3a0e1d5f92..ab49905409 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -271,7 +271,7 @@ func (s *Server) ListAppKeys(ctx context.Context, req *mgmt_pb.ListAppKeysReques if err != nil { return nil, err } - keys, err := s.query.SearchAuthNKeys(ctx, queries, false) + keys, err := s.query.SearchAuthNKeys(ctx, queries, query.JoinFilterApp, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index f318051e63..ae1040cd1e 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -297,7 +297,7 @@ func (s *Server) ImportHumanUser(ctx context.Context, req *mgmt_pb.ImportHumanUs func (s *Server) AddMachineUser(ctx context.Context, req *mgmt_pb.AddMachineUserRequest) (*mgmt_pb.AddMachineUserResponse, error) { machine := AddMachineUserRequestToCommand(req, authz.GetCtxData(ctx).OrgID) - objectDetails, err := s.command.AddMachine(ctx, machine) + objectDetails, err := s.command.AddMachine(ctx, machine, nil) if err != nil { return nil, err } @@ -752,11 +752,11 @@ func (s *Server) GetMachineKeyByIDs(ctx context.Context, req *mgmt_pb.GetMachine } func (s *Server) ListMachineKeys(ctx context.Context, req *mgmt_pb.ListMachineKeysRequest) (*mgmt_pb.ListMachineKeysResponse, error) { - query, err := ListMachineKeysRequestToQuery(ctx, req) + q, err := ListMachineKeysRequestToQuery(ctx, req) if err != nil { return nil, err } - result, err := s.query.SearchAuthNKeys(ctx, query, false) + result, err := s.query.SearchAuthNKeys(ctx, q, query.JoinFilterUserMachine, nil) if err != nil { return nil, err } @@ -774,7 +774,6 @@ func (s *Server) AddMachineKey(ctx context.Context, req *mgmt_pb.AddMachineKeyRe if err != nil { return nil, err } - // Return key details only if the pubkey wasn't supplied, otherwise the user already has // private key locally var keyDetails []byte @@ -821,7 +820,7 @@ func (s *Server) GenerateMachineSecret(ctx context.Context, req *mgmt_pb.Generat } func (s *Server) RemoveMachineSecret(ctx context.Context, req *mgmt_pb.RemoveMachineSecretRequest) (*mgmt_pb.RemoveMachineSecretResponse, error) { - objectDetails, err := s.command.RemoveMachineSecret(ctx, req.UserId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.RemoveMachineSecret(ctx, req.UserId, authz.GetCtxData(ctx).OrgID, nil) if err != nil { return nil, err } @@ -839,7 +838,7 @@ func (s *Server) GetPersonalAccessTokenByIDs(ctx context.Context, req *mgmt_pb.G if err != nil { return nil, err } - token, err := s.query.PersonalAccessTokenByID(ctx, true, req.TokenId, false, resourceOwner, aggregateID) + token, err := s.query.PersonalAccessTokenByID(ctx, true, req.TokenId, resourceOwner, aggregateID) if err != nil { return nil, err } @@ -853,7 +852,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *mgmt_pb.List if err != nil { return nil, err } - result, err := s.query.SearchPersonalAccessTokens(ctx, queries, false) + result, err := s.query.SearchPersonalAccessTokens(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/project/v2beta/integration/query_test.go b/internal/api/grpc/project/v2beta/integration/query_test.go index f959bfe2f8..517f103628 100644 --- a/internal/api/grpc/project/v2beta/integration/query_test.go +++ b/internal/api/grpc/project/v2beta/integration/query_test.go @@ -168,8 +168,8 @@ func TestServer_ListProjects(t *testing.T) { orgID := instance.DefaultOrg.GetId() resp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -188,8 +188,8 @@ func TestServer_ListProjects(t *testing.T) { orgID := instance.DefaultOrg.GetId() resp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -208,8 +208,8 @@ func TestServer_ListProjects(t *testing.T) { orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -232,8 +232,8 @@ func TestServer_ListProjects(t *testing.T) { req: &project.ListProjectsRequest{ Filters: []*project.ProjectSearchFilter{ {Filter: &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{"notfound"}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{"notfound"}, }, }, }, @@ -255,8 +255,8 @@ func TestServer_ListProjects(t *testing.T) { orgID := instance.DefaultOrg.GetId() response.Projects[0] = createProject(iamOwnerCtx, instance, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId()}, }, } }, @@ -317,8 +317,8 @@ func TestServer_ListProjects(t *testing.T) { response.Projects[1] = createProject(iamOwnerCtx, instance, t, orgID, true, false) response.Projects[0] = createProject(iamOwnerCtx, instance, t, orgID, false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, }, } }, @@ -349,8 +349,8 @@ func TestServer_ListProjects(t *testing.T) { resp2 := createProject(iamOwnerCtx, instance, t, orgID, true, false) resp3 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, }, } @@ -379,8 +379,8 @@ func TestServer_ListProjects(t *testing.T) { projectResp := createProject(iamOwnerCtx, instance, t, orgID, true, true) response.Projects[3] = projectResp request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } response.Projects[2] = createGrantedProject(iamOwnerCtx, instance, t, projectResp) @@ -416,7 +416,7 @@ func TestServer_ListProjects(t *testing.T) { response.Projects[1] = grantedProjectResp response.Projects[0] = createProject(iamOwnerCtx, instance, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectOrganizationIdFilter{ - ProjectOrganizationIdFilter: &project.ProjectOrganizationIDFilter{ProjectOrganizationId: *grantedProjectResp.GrantedOrganizationId}, + ProjectOrganizationIdFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -445,7 +445,7 @@ func TestServer_ListProjects(t *testing.T) { grantedProjectResp := createGrantedProject(iamOwnerCtx, instance, t, projectResp) response.Projects[0] = createProject(iamOwnerCtx, instance, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectResourceOwnerFilter{ - ProjectResourceOwnerFilter: &project.ProjectResourceOwnerFilter{ProjectResourceOwner: *grantedProjectResp.GrantedOrganizationId}, + ProjectResourceOwnerFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -513,8 +513,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -531,8 +531,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -550,8 +550,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) resp := createProject(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, }, } }, @@ -574,8 +574,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { req: &project.ListProjectsRequest{ Filters: []*project.ProjectSearchFilter{ {Filter: &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{"notfound"}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{"notfound"}, }, }, }, @@ -596,8 +596,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, false) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId()}, }, } }, @@ -650,8 +650,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { response.Projects[1] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, false) response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, orgID, false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{response.Projects[0].GetId(), response.Projects[1].GetId(), response.Projects[2].GetId()}, }, } }, @@ -679,8 +679,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { projectResp := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, true) response.Projects[3] = projectResp request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } response.Projects[2] = createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) @@ -715,7 +715,7 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { response.Projects[1] = grantedProjectResp response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectOrganizationIdFilter{ - ProjectOrganizationIdFilter: &project.ProjectOrganizationIDFilter{ProjectOrganizationId: *grantedProjectResp.GrantedOrganizationId}, + ProjectOrganizationIdFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -743,7 +743,7 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { grantedProjectResp := createGrantedProject(iamOwnerCtx, instancePermissionV2, t, projectResp) response.Projects[0] = createProject(iamOwnerCtx, instancePermissionV2, t, *grantedProjectResp.GrantedOrganizationId, true, true) request.Filters[0].Filter = &project.ProjectSearchFilter_ProjectResourceOwnerFilter{ - ProjectResourceOwnerFilter: &project.ProjectResourceOwnerFilter{ProjectResourceOwner: *grantedProjectResp.GrantedOrganizationId}, + ProjectResourceOwnerFilter: &filter.IDFilter{Id: *grantedProjectResp.GrantedOrganizationId}, } }, req: &project.ListProjectsRequest{ @@ -770,8 +770,8 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { resp2 := createProject(iamOwnerCtx, instancePermissionV2, t, orgID, true, false) resp3 := createProject(iamOwnerCtx, instancePermissionV2, t, orgResp.GetOrganizationId(), false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId()}, }, } @@ -882,15 +882,13 @@ func TestServer_ListProjectGrants(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -908,15 +906,13 @@ func TestServer_ListProjectGrants(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -934,8 +930,8 @@ func TestServer_ListProjectGrants(t *testing.T) { req: &project.ListProjectGrantsRequest{ Filters: []*project.ProjectGrantSearchFilter{ {Filter: &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{"notfound"}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{"notfound"}, }, }, }, @@ -958,8 +954,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -988,8 +984,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1016,8 +1012,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1053,8 +1049,8 @@ func TestServer_ListProjectGrants(t *testing.T) { project2Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) project3Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, }, } @@ -1084,8 +1080,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } projectRoleResp := addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) @@ -1114,8 +1110,8 @@ func TestServer_ListProjectGrants(t *testing.T) { orgID := instance.DefaultOrg.GetId() projectResp := instance.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } projectRoleResp := addProjectRole(iamOwnerCtx, instance, t, projectResp.GetId()) @@ -1189,15 +1185,13 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -1215,15 +1209,13 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), gofakeit.AppName(), false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } grantedOrg := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) request.Filters[1].Filter = &project.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwnerFilter: &project.ProjectGrantResourceOwnerFilter{ - ProjectGrantResourceOwner: grantedOrg.GetOrganizationId(), - }, + ProjectGrantResourceOwnerFilter: &filter.IDFilter{Id: grantedOrg.GetOrganizationId()}, } instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) @@ -1243,8 +1235,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { orgID := instancePermissionV2.DefaultOrg.GetId() projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1273,8 +1265,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1301,8 +1293,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { orgID := instancePermissionV2.DefaultOrg.GetId() projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgID, name, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{projectResp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{projectResp.GetId()}, }, } @@ -1338,8 +1330,8 @@ func TestServer_ListProjectGrants_PermissionV2(t *testing.T) { project2Resp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) project3Resp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId()}, }, } diff --git a/internal/api/grpc/project/v2beta/query.go b/internal/api/grpc/project/v2beta/query.go index 1cdf9eefbd..42b69a480e 100644 --- a/internal/api/grpc/project/v2beta/query.go +++ b/internal/api/grpc/project/v2beta/query.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) @@ -109,20 +110,20 @@ func projectNameFilterToQuery(q *project_pb.ProjectNameFilter) (query.SearchQuer return query.NewGrantedProjectNameSearchQuery(filter.TextMethodPbToQuery(q.Method), q.GetProjectName()) } -func projectInIDsFilterToQuery(q *project_pb.InProjectIDsFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectIDSearchQuery(q.ProjectIds) +func projectInIDsFilterToQuery(q *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectIDSearchQuery(q.Ids) } -func projectResourceOwnerFilterToQuery(q *project_pb.ProjectResourceOwnerFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectResourceOwnerSearchQuery(q.ProjectResourceOwner) +func projectResourceOwnerFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectResourceOwnerSearchQuery(q.Id) } -func projectOrganizationIDFilterToQuery(q *project_pb.ProjectOrganizationIDFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectOrganizationIDSearchQuery(q.ProjectOrganizationId) +func projectOrganizationIDFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectOrganizationIDSearchQuery(q.Id) } -func projectGrantResourceOwnerFilterToQuery(q *project_pb.ProjectGrantResourceOwnerFilter) (query.SearchQuery, error) { - return query.NewGrantedProjectGrantResourceOwnerSearchQuery(q.ProjectGrantResourceOwner) +func projectGrantResourceOwnerFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewGrantedProjectGrantResourceOwnerSearchQuery(q.Id) } func grantedProjectsToPb(projects []*query.GrantedProject) []*project_pb.Project { @@ -283,11 +284,11 @@ func projectGrantFilterToModel(filter *project_pb.ProjectGrantSearchFilter) (que case *project_pb.ProjectGrantSearchFilter_RoleKeyFilter: return query.NewProjectGrantRoleKeySearchQuery(q.RoleKeyFilter.Key) case *project_pb.ProjectGrantSearchFilter_InProjectIdsFilter: - return query.NewProjectGrantProjectIDsSearchQuery(q.InProjectIdsFilter.ProjectIds) + return query.NewProjectGrantProjectIDsSearchQuery(q.InProjectIdsFilter.Ids) case *project_pb.ProjectGrantSearchFilter_ProjectResourceOwnerFilter: - return query.NewProjectGrantResourceOwnerSearchQuery(q.ProjectResourceOwnerFilter.ProjectResourceOwner) + return query.NewProjectGrantResourceOwnerSearchQuery(q.ProjectResourceOwnerFilter.Id) case *project_pb.ProjectGrantSearchFilter_ProjectGrantResourceOwnerFilter: - return query.NewProjectGrantGrantedOrgIDSearchQuery(q.ProjectGrantResourceOwnerFilter.ProjectGrantResourceOwner) + return query.NewProjectGrantGrantedOrgIDSearchQuery(q.ProjectGrantResourceOwnerFilter.Id) default: return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-M099f", "List.Query.Invalid") } diff --git a/internal/api/grpc/user/v2/human.go b/internal/api/grpc/user/v2/human.go new file mode 100644 index 0000000000..d8a0891396 --- /dev/null +++ b/internal/api/grpc/user/v2/human.go @@ -0,0 +1,187 @@ +package user + +import ( + "context" + "io" + + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + legacyobject "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*user.CreateUserResponse, error) { + addHumanPb := &user.AddHumanUserRequest{ + Username: userName, + UserId: userId, + Organization: &legacyobject.Organization{ + Org: &legacyobject.Organization_OrgId{OrgId: orgId}, + }, + Profile: humanPb.Profile, + Email: humanPb.Email, + Phone: humanPb.Phone, + IdpLinks: humanPb.IdpLinks, + TotpSecret: humanPb.TotpSecret, + } + switch pwType := humanPb.GetPasswordType().(type) { + case *user.CreateUserRequest_Human_HashedPassword: + addHumanPb.PasswordType = &user.AddHumanUserRequest_HashedPassword{ + HashedPassword: pwType.HashedPassword, + } + case *user.CreateUserRequest_Human_Password: + addHumanPb.PasswordType = &user.AddHumanUserRequest_Password{ + Password: pwType.Password, + } + default: + // optional password is not set + } + newHuman, err := AddUserRequestToAddHuman(addHumanPb) + if err != nil { + return nil, err + } + if err = s.command.AddUserHuman( + ctx, + orgId, + newHuman, + false, + s.userCodeAlg, + ); err != nil { + return nil, err + } + return &user.CreateUserResponse{ + Id: newHuman.ID, + CreationDate: timestamppb.New(newHuman.Details.EventDate), + EmailCode: newHuman.EmailCode, + PhoneCode: newHuman.PhoneCode, + }, nil +} + +func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*user.UpdateUserResponse, error) { + cmd, err := updateHumanUserToCommand(userId, userName, humanPb) + if err != nil { + return nil, err + } + if err = s.command.ChangeUserHuman(ctx, cmd, s.userCodeAlg); err != nil { + return nil, err + } + return &user.UpdateUserResponse{ + ChangeDate: timestamppb.New(cmd.Details.EventDate), + EmailCode: cmd.EmailCode, + PhoneCode: cmd.PhoneCode, + }, nil +} + +func updateHumanUserToCommand(userId string, userName *string, human *user.UpdateUserRequest_Human) (*command.ChangeHuman, error) { + phone := human.GetPhone() + if phone != nil && phone.Phone == "" && phone.GetVerification() != nil { + return nil, zerrors.ThrowInvalidArgument(nil, "USERv2-4f3d6", "Errors.User.Phone.VerifyingRemovalIsNotSupported") + } + email, err := setHumanEmailToEmail(human.Email, userId) + if err != nil { + return nil, err + } + return &command.ChangeHuman{ + ID: userId, + Username: userName, + Profile: SetHumanProfileToProfile(human.Profile), + Email: email, + Phone: setHumanPhoneToPhone(human.Phone, true), + Password: setHumanPasswordToPassword(human.Password), + }, nil +} + +func updateHumanUserRequestToChangeHuman(req *user.UpdateHumanUserRequest) (*command.ChangeHuman, error) { + email, err := setHumanEmailToEmail(req.Email, req.GetUserId()) + if err != nil { + return nil, err + } + changeHuman := &command.ChangeHuman{ + ID: req.GetUserId(), + Username: req.Username, + Email: email, + Phone: setHumanPhoneToPhone(req.Phone, false), + Password: setHumanPasswordToPassword(req.Password), + } + if profile := req.GetProfile(); profile != nil { + var firstName *string + if profile.GivenName != "" { + firstName = &profile.GivenName + } + var lastName *string + if profile.FamilyName != "" { + lastName = &profile.FamilyName + } + changeHuman.Profile = SetHumanProfileToProfile(&user.UpdateUserRequest_Human_Profile{ + GivenName: firstName, + FamilyName: lastName, + NickName: profile.NickName, + DisplayName: profile.DisplayName, + PreferredLanguage: profile.PreferredLanguage, + Gender: profile.Gender, + }) + } + return changeHuman, nil +} + +func SetHumanProfileToProfile(profile *user.UpdateUserRequest_Human_Profile) *command.Profile { + if profile == nil { + return nil + } + return &command.Profile{ + FirstName: profile.GivenName, + LastName: profile.FamilyName, + NickName: profile.NickName, + DisplayName: profile.DisplayName, + PreferredLanguage: ifNotNilPtr(profile.PreferredLanguage, language.Make), + Gender: ifNotNilPtr(profile.Gender, genderToDomain), + } +} + +func setHumanEmailToEmail(email *user.SetHumanEmail, userID string) (*command.Email, error) { + if email == nil { + return nil, nil + } + var urlTemplate string + if email.GetSendCode() != nil && email.GetSendCode().UrlTemplate != nil { + urlTemplate = *email.GetSendCode().UrlTemplate + if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, userID, "code", "orgID"); err != nil { + return nil, err + } + } + return &command.Email{ + Address: domain.EmailAddress(email.Email), + Verified: email.GetIsVerified(), + ReturnCode: email.GetReturnCode() != nil, + URLTemplate: urlTemplate, + }, nil +} + +func setHumanPhoneToPhone(phone *user.SetHumanPhone, withRemove bool) *command.Phone { + if phone == nil { + return nil + } + number := phone.GetPhone() + return &command.Phone{ + Number: domain.PhoneNumber(number), + Verified: phone.GetIsVerified(), + ReturnCode: phone.GetReturnCode() != nil, + Remove: withRemove && number == "", + } +} + +func setHumanPasswordToPassword(password *user.SetPassword) *command.Password { + if password == nil { + return nil + } + return &command.Password{ + PasswordCode: password.GetVerificationCode(), + OldPassword: password.GetCurrentPassword(), + Password: password.GetPassword().GetPassword(), + EncodedPasswordHash: password.GetHashedPassword().GetHash(), + ChangeRequired: password.GetPassword().GetChangeRequired() || password.GetHashedPassword().GetChangeRequired(), + } +} diff --git a/internal/api/grpc/user/v2/human_test.go b/internal/api/grpc/user/v2/human_test.go new file mode 100644 index 0000000000..52e5371dcc --- /dev/null +++ b/internal/api/grpc/user/v2/human_test.go @@ -0,0 +1,254 @@ +package user + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func Test_patchHumanUserToCommand(t *testing.T) { + type args struct { + userId string + userName *string + human *user.UpdateUserRequest_Human + } + tests := []struct { + name string + args args + want *command.ChangeHuman + wantErr assert.ErrorAssertionFunc + }{{ + name: "single property", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("givenName"), + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Profile: &command.Profile{ + FirstName: gu.Ptr("givenName"), + }, + }, + wantErr: assert.NoError, + }, { + name: "all properties", + args: args{ + userId: "userId", + userName: gu.Ptr("userName"), + human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("givenName"), + FamilyName: gu.Ptr("familyName"), + NickName: gu.Ptr("nickName"), + DisplayName: gu.Ptr("displayName"), + PreferredLanguage: gu.Ptr("en-US"), + Gender: gu.Ptr(user.Gender_GENDER_FEMALE), + }, + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Phone: &user.SetHumanPhone{ + Phone: "+123456789", + Verification: &user.SetHumanPhone_IsVerified{ + IsVerified: true, + }, + }, + Password: &user.SetPassword{ + Verification: &user.SetPassword_CurrentPassword{ + CurrentPassword: "currentPassword", + }, + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "newPassword", + ChangeRequired: true, + }, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Username: gu.Ptr("userName"), + Profile: &command.Profile{ + FirstName: gu.Ptr("givenName"), + LastName: gu.Ptr("familyName"), + NickName: gu.Ptr("nickName"), + DisplayName: gu.Ptr("displayName"), + PreferredLanguage: &language.AmericanEnglish, + Gender: gu.Ptr(domain.GenderFemale), + }, + Email: &command.Email{ + Address: "email@example.com", + Verified: true, + }, + Phone: &command.Phone{ + Number: "+123456789", + Verified: true, + }, + Password: &command.Password{ + OldPassword: "currentPassword", + Password: "newPassword", + ChangeRequired: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "set email and request code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Email: &command.Email{ + Address: "email@example.com", + ReturnCode: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "set email and send code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Email: &command.Email{ + Address: "email@example.com", + }, + }, + wantErr: assert.NoError, + }, { + name: "set email and send code with template", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: "email@example.com", + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("Code: {{.Code}}"), + }, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Email: &command.Email{ + Address: "email@example.com", + URLTemplate: "Code: {{.Code}}", + }, + }, + wantErr: assert.NoError, + }, { + name: "set phone and request code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Phone: "+123456789", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Phone: &command.Phone{ + Number: "+123456789", + ReturnCode: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "set phone and send code", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Phone: "+123456789", + Verification: &user.SetHumanPhone_SendCode{ + SendCode: &user.SendPhoneVerificationCode{}, + }, + }, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Phone: &command.Phone{ + Number: "+123456789", + }, + }, + wantErr: assert.NoError, + }, { + name: "remove phone, ok", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{}, + }, + }, + want: &command.ChangeHuman{ + ID: "userId", + Phone: &command.Phone{ + Remove: true, + }, + }, + wantErr: assert.NoError, + }, { + name: "remove phone with verification, error", + args: args{ + userId: "userId", + human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Verification: &user.SetHumanPhone_ReturnCode{}, + }, + }, + }, + wantErr: assert.Error, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := updateHumanUserToCommand(tt.args.userId, tt.args.userName, tt.args.human) + if !tt.wantErr(t, err, fmt.Sprintf("patchHumanUserToCommand(%v, %v, %v)", tt.args.userId, tt.args.userName, tt.args.human)) { + return + } + if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(language.Tag{})); diff != "" { + t.Errorf("patchHumanUserToCommand() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/api/grpc/user/v2/integration_test/email_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go index ad63c2ce5e..ad68ef5c5a 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -10,13 +10,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" - + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func TestServer_SetEmail(t *testing.T) { +func TestServer_Deprecated_SetEmail(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { diff --git a/internal/api/grpc/user/v2/integration_test/key_test.go b/internal/api/grpc/user/v2/integration_test/key_test.go new file mode 100644 index 0000000000..e85903b2cb --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/key_test.go @@ -0,0 +1,659 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_AddKey(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.AddKeyRequest + prepare func(request *user.AddKeyRequest) error + } + tests := []struct { + name string + args args + wantErr bool + wantEmtpyKey bool + }{ + { + name: "add key, user not existing", + args: args{ + &user.AddKeyRequest{ + UserId: "notexisting", + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "generate key pair, ok", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + return nil + }, + }, + }, + { + name: "add valid public key, ok", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + // This is the public key of the tester system user. This must be valid. + PublicKey: []byte(` +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzi+FFSJL7f5yw4KTwzgM +P34ePGycm/M+kT0M7V4Cgx5V3EaDIvTQKTLfBaEB45zb9LtjIXzDw0rXRoS2hO6t +h+CYQCz3KCvh09C0IzxZiB2IS3H/aT+5Bx9EFY+vnAkZjccbyG5YNRvmtOlnvIeI +H7qZ0tEwkPfF5GEZNPJPtmy3UGV7iofdVQS1xRj73+aMw5rvH4D8IdyiAC3VekIb +pt0Vj0SUX3DwKtog337BzTiPk3aXRF0sbFhQoqdJRI8NqgZjCwjq9yfI5tyxYswn ++JGzHGdHvW3idODlmwEt5K2pasiRIWK2OGfq+w0EcltQHabuqEPgZlmhCkRdNfix +BwIDAQAB +-----END PUBLIC KEY----- +`), + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + return nil + }, + }, + wantEmtpyKey: true, + }, + { + name: "add invalid public key, error", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + PublicKey: []byte(` +-----BEGIN PUBLIC KEY----- +abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 +-----END PUBLIC KEY----- +`), + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + return nil + }, + }, + wantErr: true, + }, + { + name: "add key human, error", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { + resp := Instance.CreateUserTypeHuman(IamCTX) + request.UserId = resp.Id + return nil + }, + }, + wantErr: true, + }, + { + name: "add another key, ok", + args: args{ + &user.AddKeyRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddKeyRequest) error { + request.UserId = userId + _, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.AddKey(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.KeyId, "key id is empty") + if tt.wantEmtpyKey { + assert.Empty(t, got.KeyContent, "key content is not empty") + } else { + assert.NotEmpty(t, got.KeyContent, "key content is empty") + } + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_AddKey_Permission(t *testing.T) { + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddKey-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + request := &user.AddKeyRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + } + type args struct { + ctx context.Context + req *user.AddKeyRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request}, + }, + { + name: "instance, ok", + args: args{IamCTX, request}, + }, + { + name: "org, error", + args: args{OrgCTX, request}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, err) + got, err := Client.AddKey(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.KeyId, "key id is empty") + assert.NotEmpty(t, got.KeyContent, "key content is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveKey(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.RemoveKeyRequest + prepare func(request *user.RemoveKeyRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "remove key, user not existing", + args: args{ + &user.RemoveKeyRequest{ + UserId: "notexisting", + }, + func(request *user.RemoveKeyRequest) error { + key, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.KeyId = key.GetKeyId() + return err + }, + }, + wantErr: true, + }, + { + name: "remove key, not existing", + args: args{ + &user.RemoveKeyRequest{ + KeyId: "notexisting", + }, + func(request *user.RemoveKeyRequest) error { + request.UserId = userId + return nil + }, + }, + wantErr: true, + }, + { + name: "remove key, ok", + args: args{ + &user.RemoveKeyRequest{}, + func(request *user.RemoveKeyRequest) error { + key, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.KeyId = key.GetKeyId() + request.UserId = userId + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.RemoveKey(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + deletionDate := got.DeletionDate.AsTime() + assert.Greater(t, deletionDate, now, "creation date is before the test started") + assert.Less(t, deletionDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveKey_Permission(t *testing.T) { + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("RemoveKey-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + request := &user.RemoveKeyRequest{ + UserId: otherOrgUser.GetId(), + } + prepare := func(request *user.RemoveKeyRequest) error { + key, err := Client.AddKey(IamCTX, &user.AddKeyRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + }) + request.KeyId = key.GetKeyId() + return err + } + require.NoError(t, err) + type args struct { + ctx context.Context + req *user.RemoveKeyRequest + prepare func(request *user.RemoveKeyRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request, prepare}, + }, + { + name: "instance, ok", + args: args{IamCTX, request, prepare}, + }, + { + name: "org, error", + args: args{OrgCTX, request, prepare}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request, prepare}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, tt.args.prepare(tt.args.req)) + got, err := Client.RemoveKey(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "client key is empty") + creationDate := got.DeletionDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_ListKeys(t *testing.T) { + type args struct { + ctx context.Context + req *user.ListKeysRequest + } + type testCase struct { + name string + args args + want *user.ListKeysResponse + } + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(SystemCTX, fmt.Sprintf("ListKeys-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(SystemCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + otherOrgUserId := otherOrgUser.GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + onlySinceTestStartFilter := &user.KeysSearchFilter{Filter: &user.KeysSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ + Timestamp: timestamppb.Now(), + Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, + }}} + myOrgId := Instance.DefaultOrg.GetId() + myUserId := Instance.Users.Get(integration.UserTypeNoPermission).ID + expiresInADay := time.Now().Truncate(time.Hour).Add(time.Hour * 24) + myDataPoint := setupKeyDataPoint(t, myUserId, myOrgId, expiresInADay) + otherUserDataPoint := setupKeyDataPoint(t, otherUserId, myOrgId, expiresInADay) + otherOrgDataPointExpiringSoon := setupKeyDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, time.Now().Truncate(time.Hour).Add(time.Hour)) + otherOrgDataPointExpiringLate := setupKeyDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, expiresInADay.Add(time.Hour*24*30)) + sortingColumnExpirationDate := user.KeyFieldName_KEY_FIELD_NAME_KEY_EXPIRATION_DATE + awaitKeys(t, onlySinceTestStartFilter, + otherOrgDataPointExpiringSoon.GetId(), + otherOrgDataPointExpiringLate.GetId(), + otherUserDataPoint.GetId(), + myDataPoint.GetId(), + ) + tests := []testCase{ + { + name: "list all, instance", + args: args{ + IamCTX, + &user.ListKeysRequest{Filters: []*user.KeysSearchFilter{onlySinceTestStartFilter}}, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, org", + args: args{ + OrgCTX, + &user.ListKeysRequest{Filters: []*user.KeysSearchFilter{onlySinceTestStartFilter}}, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, user", + args: args{ + UserCTX, + &user.ListKeysRequest{Filters: []*user.KeysSearchFilter{onlySinceTestStartFilter}}, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list by id", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.KeysSearchFilter_KeyIdFilter{ + KeyIdFilter: &filter.IDFilter{Id: otherOrgDataPointExpiringSoon.Id}, + }, + }, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all from other org", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.KeysSearchFilter_OrganizationIdFilter{ + OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}, + }, + }, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "sort by next expiration dates", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + SortingColumn: &sortingColumnExpirationDate, + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + {Filter: &user.KeysSearchFilter_OrganizationIdFilter{OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}}}, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "get page", + args: args{ + IamCTX, + &user.ListKeysRequest{ + Pagination: &filter.PaginationRequest{ + Offset: 2, + Limit: 2, + Asc: true, + }, + Filters: []*user.KeysSearchFilter{ + onlySinceTestStartFilter, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 2, + }, + }, + }, + { + name: "empty list", + args: args{ + UserCTX, + &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{ + { + Filter: &user.KeysSearchFilter_KeyIdFilter{ + KeyIdFilter: &filter.IDFilter{Id: otherUserDataPoint.Id}, + }, + }, + }, + }, + }, + want: &user.ListKeysResponse{ + Result: []*user.Key{}, + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + } + t.Run("with permission flag v2", func(t *testing.T) { + setPermissionCheckV2Flag(t, true) + defer setPermissionCheckV2Flag(t, false) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListKeys(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListKeys() mismatch (-want +got):\n%s", diff) + } + }) + } + }) + t.Run("without permission flag v2", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListKeys(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + // ignore the total result, as this is a known bug with the in-memory permission checks. + // The command can't know how many keys exist in the system if the SQL statement has a limit. + // This is fixed, once the in-memory permission checks are removed with https://github.com/zitadel/zitadel/issues/9188 + tt.want.Pagination.TotalResult = got.Pagination.TotalResult + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListKeys() mismatch (-want +got):\n%s", diff) + } + }) + } + }) +} + +func setupKeyDataPoint(t *testing.T, userId, orgId string, expirationDate time.Time) *user.Key { + expirationDatePb := timestamppb.New(expirationDate) + newKey, err := Client.AddKey(SystemCTX, &user.AddKeyRequest{ + UserId: userId, + ExpirationDate: expirationDatePb, + PublicKey: nil, + }) + require.NoError(t, err) + return &user.Key{ + CreationDate: newKey.CreationDate, + ChangeDate: newKey.CreationDate, + Id: newKey.GetKeyId(), + UserId: userId, + OrganizationId: orgId, + ExpirationDate: expirationDatePb, + } +} + +func awaitKeys(t *testing.T, sinceTestStartFilter *user.KeysSearchFilter, keyIds ...string) { + sortingColumn := user.KeyFieldName_KEY_FIELD_NAME_ID + slices.Sort(keyIds) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + result, err := Client.ListKeys(SystemCTX, &user.ListKeysRequest{ + Filters: []*user.KeysSearchFilter{sinceTestStartFilter}, + SortingColumn: &sortingColumn, + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + }) + require.NoError(t, err) + if !assert.Len(collect, result.Result, len(keyIds)) { + return + } + for i := range keyIds { + keyId := keyIds[i] + require.Equal(collect, keyId, result.Result[i].GetId()) + } + }, 5*time.Second, time.Second, "key not created in time") +} diff --git a/internal/api/grpc/user/v2/integration_test/password_test.go b/internal/api/grpc/user/v2/integration_test/password_test.go index 0cd0da7454..258cdaf78d 100644 --- a/internal/api/grpc/user/v2/integration_test/password_test.go +++ b/internal/api/grpc/user/v2/integration_test/password_test.go @@ -104,7 +104,7 @@ func TestServer_RequestPasswordReset(t *testing.T) { } } -func TestServer_SetPassword(t *testing.T) { +func TestServer_Deprecated_SetPassword(t *testing.T) { type args struct { ctx context.Context req *user.SetPasswordRequest diff --git a/internal/api/grpc/user/v2/integration_test/pat_test.go b/internal/api/grpc/user/v2/integration_test/pat_test.go new file mode 100644 index 0000000000..ce974e0407 --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/pat_test.go @@ -0,0 +1,615 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_AddPersonalAccessToken(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.AddPersonalAccessTokenRequest + prepare func(request *user.AddPersonalAccessTokenRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "add pat, user not existing", + args: args{ + &user.AddPersonalAccessTokenRequest{ + UserId: "notexisting", + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "add pat, ok", + args: args{ + &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { + request.UserId = userId + return nil + }, + }, + }, + { + name: "add pat human, not ok", + args: args{ + &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { + resp := Instance.CreateUserTypeHuman(IamCTX) + request.UserId = resp.Id + return nil + }, + }, + wantErr: true, + }, + { + name: "add another pat, ok", + args: args{ + &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + }, + func(request *user.AddPersonalAccessTokenRequest) error { + request.UserId = userId + _, err := Client.AddPersonalAccessToken(IamCTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.AddPersonalAccessToken(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.TokenId, "id is empty") + assert.NotEmpty(t, got.Token, "token is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_AddPersonalAccessToken_Permission(t *testing.T) { + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddPersonalAccessToken-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + request := &user.AddPersonalAccessTokenRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + } + type args struct { + ctx context.Context + req *user.AddPersonalAccessTokenRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request}, + }, + { + name: "instance, ok", + args: args{IamCTX, request}, + }, + { + name: "org, error", + args: args{OrgCTX, request}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, err) + got, err := Client.AddPersonalAccessToken(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.TokenId, "id is empty") + assert.NotEmpty(t, got.Token, "token is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemovePersonalAccessToken(t *testing.T) { + resp := Instance.CreateUserTypeMachine(IamCTX) + userId := resp.GetId() + expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) + type args struct { + req *user.RemovePersonalAccessTokenRequest + prepare func(request *user.RemovePersonalAccessTokenRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "remove pat, user not existing", + args: args{ + &user.RemovePersonalAccessTokenRequest{ + UserId: "notexisting", + }, + func(request *user.RemovePersonalAccessTokenRequest) error { + pat, err := Client.AddPersonalAccessToken(CTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.TokenId = pat.GetTokenId() + return err + }, + }, + wantErr: true, + }, + { + name: "remove pat, not existing", + args: args{ + &user.RemovePersonalAccessTokenRequest{ + TokenId: "notexisting", + }, + func(request *user.RemovePersonalAccessTokenRequest) error { + request.UserId = userId + return nil + }, + }, + wantErr: true, + }, + { + name: "remove pat, ok", + args: args{ + &user.RemovePersonalAccessTokenRequest{}, + func(request *user.RemovePersonalAccessTokenRequest) error { + pat, err := Client.AddPersonalAccessToken(CTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: expirationDate, + UserId: userId, + }) + request.TokenId = pat.GetTokenId() + request.UserId = userId + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.RemovePersonalAccessToken(CTX, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + deletionDate := got.DeletionDate.AsTime() + assert.Greater(t, deletionDate, now, "creation date is before the test started") + assert.Less(t, deletionDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemovePersonalAccessToken_Permission(t *testing.T) { + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("RemovePersonalAccessToken-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + request := &user.RemovePersonalAccessTokenRequest{ + UserId: otherOrgUser.GetId(), + } + prepare := func(request *user.RemovePersonalAccessTokenRequest) error { + pat, err := Client.AddPersonalAccessToken(IamCTX, &user.AddPersonalAccessTokenRequest{ + ExpirationDate: timestamppb.New(time.Now().Add(time.Hour * 24)), + UserId: otherOrgUser.GetId(), + }) + request.TokenId = pat.GetTokenId() + return err + } + require.NoError(t, err) + type args struct { + ctx context.Context + req *user.RemovePersonalAccessTokenRequest + prepare func(request *user.RemovePersonalAccessTokenRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{SystemCTX, request, prepare}, + }, + { + name: "instance, ok", + args: args{IamCTX, request, prepare}, + }, + { + name: "org, error", + args: args{CTX, request, prepare}, + wantErr: true, + }, + { + name: "user, error", + args: args{UserCTX, request, prepare}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, tt.args.prepare(tt.args.req)) + got, err := Client.RemovePersonalAccessToken(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "client pat is empty") + creationDate := got.DeletionDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_ListPersonalAccessTokens(t *testing.T) { + type args struct { + ctx context.Context + req *user.ListPersonalAccessTokensRequest + } + type testCase struct { + name string + args args + want *user.ListPersonalAccessTokensResponse + } + OrgCTX := CTX + otherOrg := Instance.CreateOrganization(SystemCTX, fmt.Sprintf("ListPersonalAccessTokens-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Client.CreateUser(SystemCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + otherOrgUserId := otherOrgUser.GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + onlySinceTestStartFilter := &user.PersonalAccessTokensSearchFilter{Filter: &user.PersonalAccessTokensSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ + Timestamp: timestamppb.Now(), + Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, + }}} + myOrgId := Instance.DefaultOrg.GetId() + myUserId := Instance.Users.Get(integration.UserTypeNoPermission).ID + expiresInADay := time.Now().Truncate(time.Hour).Add(time.Hour * 24) + myDataPoint := setupPATDataPoint(t, myUserId, myOrgId, expiresInADay) + otherUserDataPoint := setupPATDataPoint(t, otherUserId, myOrgId, expiresInADay) + otherOrgDataPointExpiringSoon := setupPATDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, time.Now().Truncate(time.Hour).Add(time.Hour)) + otherOrgDataPointExpiringLate := setupPATDataPoint(t, otherOrgUserId, otherOrg.OrganizationId, expiresInADay.Add(time.Hour*24*30)) + sortingColumnExpirationDate := user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_EXPIRATION_DATE + awaitPersonalAccessTokens(t, + onlySinceTestStartFilter, + otherOrgDataPointExpiringSoon.GetId(), + otherOrgDataPointExpiringLate.GetId(), + otherUserDataPoint.GetId(), + myDataPoint.GetId(), + ) + tests := []testCase{ + { + name: "list all, instance", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{onlySinceTestStartFilter}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, org", + args: args{ + OrgCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{onlySinceTestStartFilter}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherUserDataPoint, + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all, user", + args: args{ + UserCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{onlySinceTestStartFilter}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + myDataPoint, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list by id", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.PersonalAccessTokensSearchFilter_TokenIdFilter{ + TokenIdFilter: &filter.IDFilter{Id: otherOrgDataPointExpiringSoon.Id}, + }, + }, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + }, + }, + { + name: "list all from other org", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + { + Filter: &user.PersonalAccessTokensSearchFilter_OrganizationIdFilter{ + OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}, + }, + }}, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringLate, + otherOrgDataPointExpiringSoon, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "sort by next expiration dates", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + SortingColumn: &sortingColumnExpirationDate, + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + {Filter: &user.PersonalAccessTokensSearchFilter_OrganizationIdFilter{OrganizationIdFilter: &filter.IDFilter{Id: otherOrg.OrganizationId}}}, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + }, + }, + { + name: "get page", + args: args{ + IamCTX, + &user.ListPersonalAccessTokensRequest{ + Pagination: &filter.PaginationRequest{ + Offset: 2, + Limit: 2, + Asc: true, + }, + Filters: []*user.PersonalAccessTokensSearchFilter{ + onlySinceTestStartFilter, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{ + otherOrgDataPointExpiringSoon, + otherOrgDataPointExpiringLate, + }, + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 2, + }, + }, + }, + { + name: "empty list", + args: args{ + UserCTX, + &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{ + { + Filter: &user.PersonalAccessTokensSearchFilter_TokenIdFilter{ + TokenIdFilter: &filter.IDFilter{Id: otherUserDataPoint.Id}, + }, + }, + }, + }, + }, + want: &user.ListPersonalAccessTokensResponse{ + Result: []*user.PersonalAccessToken{}, + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + } + t.Run("with permission flag v2", func(t *testing.T) { + setPermissionCheckV2Flag(t, true) + defer setPermissionCheckV2Flag(t, false) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListPersonalAccessTokens(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListPersonalAccessTokens() mismatch (-want +got):\n%s", diff) + } + }) + } + }) + t.Run("without permission flag v2", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Client.ListPersonalAccessTokens(tt.args.ctx, tt.args.req) + require.NoError(t, err) + assert.Len(t, got.Result, len(tt.want.Result)) + // ignore the total result, as this is a known bug with the in-memory permission checks. + // The command can't know how many keys exist in the system if the SQL statement has a limit. + // This is fixed, once the in-memory permission checks are removed with https://github.com/zitadel/zitadel/issues/9188 + tt.want.Pagination.TotalResult = got.Pagination.TotalResult + if diff := cmp.Diff(tt.want, got, protocmp.Transform()); diff != "" { + t.Errorf("ListPersonalAccessTokens() mismatch (-want +got):\n%s", diff) + } + }) + } + }) +} + +func setupPATDataPoint(t *testing.T, userId, orgId string, expirationDate time.Time) *user.PersonalAccessToken { + expirationDatePb := timestamppb.New(expirationDate) + newPersonalAccessToken, err := Client.AddPersonalAccessToken(SystemCTX, &user.AddPersonalAccessTokenRequest{ + UserId: userId, + ExpirationDate: expirationDatePb, + }) + require.NoError(t, err) + return &user.PersonalAccessToken{ + CreationDate: newPersonalAccessToken.CreationDate, + ChangeDate: newPersonalAccessToken.CreationDate, + Id: newPersonalAccessToken.GetTokenId(), + UserId: userId, + OrganizationId: orgId, + ExpirationDate: expirationDatePb, + } +} + +func awaitPersonalAccessTokens(t *testing.T, sinceTestStartFilter *user.PersonalAccessTokensSearchFilter, patIds ...string) { + sortingColumn := user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_ID + slices.Sort(patIds) + require.EventuallyWithT(t, func(collect *assert.CollectT) { + result, err := Client.ListPersonalAccessTokens(SystemCTX, &user.ListPersonalAccessTokensRequest{ + Filters: []*user.PersonalAccessTokensSearchFilter{sinceTestStartFilter}, + SortingColumn: &sortingColumn, + Pagination: &filter.PaginationRequest{ + Asc: true, + }, + }) + require.NoError(t, err) + if !assert.Len(collect, result.Result, len(patIds)) { + return + } + for i := range patIds { + patId := patIds[i] + require.Equal(collect, patId, result.Result[i].GetId()) + } + }, 5*time.Second, time.Second, "pat not created in time") +} diff --git a/internal/api/grpc/user/v2/integration_test/phone_test.go b/internal/api/grpc/user/v2/integration_test/phone_test.go index 49050c5fe6..b87f9a9f28 100644 --- a/internal/api/grpc/user/v2/integration_test/phone_test.go +++ b/internal/api/grpc/user/v2/integration_test/phone_test.go @@ -17,7 +17,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func TestServer_SetPhone(t *testing.T) { +func TestServer_Deprecated_SetPhone(t *testing.T) { userID := Instance.CreateHumanUser(CTX).GetUserId() tests := []struct { @@ -249,7 +249,7 @@ func TestServer_VerifyPhone(t *testing.T) { } } -func TestServer_RemovePhone(t *testing.T) { +func TestServer_Deprecated_RemovePhone(t *testing.T) { userResp := Instance.CreateHumanUser(CTX) failResp := Instance.CreateHumanUserNoPhone(CTX) otherUser := Instance.CreateHumanUser(CTX).GetUserId() diff --git a/internal/api/grpc/user/v2/integration_test/secret_test.go b/internal/api/grpc/user/v2/integration_test/secret_test.go new file mode 100644 index 0000000000..8ff537b1fd --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/secret_test.go @@ -0,0 +1,347 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_AddSecret(t *testing.T) { + type args struct { + ctx context.Context + req *user.AddSecretRequest + prepare func(request *user.AddSecretRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "add secret, user not existing", + args: args{ + CTX, + &user.AddSecretRequest{ + UserId: "notexisting", + }, + func(request *user.AddSecretRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "add secret, ok", + args: args{ + CTX, + &user.AddSecretRequest{}, + func(request *user.AddSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + return nil + }, + }, + }, + { + name: "add secret human, not ok", + args: args{ + CTX, + &user.AddSecretRequest{}, + func(request *user.AddSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + return nil + }, + }, + }, + { + name: "overwrite secret, ok", + args: args{ + CTX, + &user.AddSecretRequest{}, + func(request *user.AddSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + _, err := Client.AddSecret(CTX, &user.AddSecretRequest{ + UserId: resp.GetId(), + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.AddSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ClientSecret, "client secret is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_AddSecret_Permission(t *testing.T) { + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddSecret-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Instance.Client.UserV2.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.AddSecretRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{ + SystemCTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + }, + { + name: "instance, ok", + args: args{ + IamCTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + }, + { + name: "org, error", + args: args{ + CTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + wantErr: true, + }, + { + name: "user, error", + args: args{ + UserCTX, + &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, err) + got, err := Client.AddSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ClientSecret, "client secret is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveSecret(t *testing.T) { + type args struct { + ctx context.Context + req *user.RemoveSecretRequest + prepare func(request *user.RemoveSecretRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "remove secret, user not existing", + args: args{ + CTX, + &user.RemoveSecretRequest{ + UserId: "notexisting", + }, + func(request *user.RemoveSecretRequest) error { return nil }, + }, + wantErr: true, + }, + { + name: "remove secret, not existing", + args: args{ + CTX, + &user.RemoveSecretRequest{}, + func(request *user.RemoveSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + return nil + }, + }, + wantErr: true, + }, + { + name: "remove secret, ok", + args: args{ + CTX, + &user.RemoveSecretRequest{}, + func(request *user.RemoveSecretRequest) error { + resp := Instance.CreateUserTypeMachine(CTX) + request.UserId = resp.GetId() + _, err := Instance.Client.UserV2.AddSecret(CTX, &user.AddSecretRequest{ + UserId: resp.GetId(), + }) + return err + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + err := tt.args.prepare(tt.args.req) + require.NoError(t, err) + got, err := Client.RemoveSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + deletionDate := got.DeletionDate.AsTime() + assert.Greater(t, deletionDate, now, "creation date is before the test started") + assert.Less(t, deletionDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_RemoveSecret_Permission(t *testing.T) { + otherOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("RemoveSecret-%s", gofakeit.AppName()), gofakeit.Email()) + otherOrgUser, err := Instance.Client.UserV2.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: otherOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }) + require.NoError(t, err) + + type args struct { + ctx context.Context + req *user.RemoveSecretRequest + prepare func(request *user.RemoveSecretRequest) error + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "system, ok", + args: args{ + SystemCTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + }, + { + name: "instance, ok", + args: args{ + IamCTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + }, + { + name: "org, error", + args: args{ + CTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + wantErr: true, + }, + { + name: "user, error", + args: args{ + UserCTX, + &user.RemoveSecretRequest{ + UserId: otherOrgUser.GetId(), + }, + func(request *user.RemoveSecretRequest) error { + _, err := Instance.Client.UserV2.AddSecret(IamCTX, &user.AddSecretRequest{ + UserId: otherOrgUser.GetId(), + }) + return err + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + require.NoError(t, tt.args.prepare(tt.args.req)) + got, err := Client.RemoveSecret(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "client secret is empty") + creationDate := got.DeletionDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 4cf4ab21f8..4eee44ab44 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -57,7 +57,7 @@ func TestMain(m *testing.M) { }()) } -func TestServer_AddHumanUser(t *testing.T) { +func TestServer_Deprecated_AddHumanUser(t *testing.T) { idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) type args struct { ctx context.Context @@ -652,6 +652,7 @@ func TestServer_AddHumanUser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { userID := fmt.Sprint(time.Now().UnixNano() + int64(i)) tt.args.req.UserId = &userID + // In order to prevent unique constraint errors, we set the email to a unique value if email := tt.args.req.GetEmail(); email != nil { email.Email = fmt.Sprintf("%s@me.now", userID) } @@ -666,7 +667,6 @@ func TestServer_AddHumanUser(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, tt.want.GetUserId(), got.GetUserId()) if tt.want.GetEmailCode() != "" { assert.NotEmpty(t, got.GetEmailCode()) @@ -683,7 +683,7 @@ func TestServer_AddHumanUser(t *testing.T) { } } -func TestServer_AddHumanUser_Permission(t *testing.T) { +func TestServer_Deprecated_AddHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) type args struct { @@ -876,7 +876,7 @@ func TestServer_AddHumanUser_Permission(t *testing.T) { } } -func TestServer_UpdateHumanUser(t *testing.T) { +func TestServer_Deprecated_UpdateHumanUser(t *testing.T) { type args struct { ctx context.Context req *user.UpdateHumanUserRequest @@ -1237,7 +1237,7 @@ func TestServer_UpdateHumanUser(t *testing.T) { } } -func TestServer_UpdateHumanUser_Permission(t *testing.T) { +func TestServer_Deprecated_UpdateHumanUser_Permission(t *testing.T) { newOrgOwnerEmail := gofakeit.Email() newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("UpdateHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) newUserID := newOrg.CreatedAdmins[0].GetUserId() @@ -1834,15 +1834,26 @@ func TestServer_DeleteUser(t *testing.T) { args: args{ req: &user.DeleteUserRequest{}, prepare: func(t *testing.T, request *user.DeleteUserRequest) context.Context { - removeUser, err := Instance.Client.Mgmt.AddMachineUser(CTX, &mgmt.AddMachineUserRequest{ - UserName: gofakeit.Username(), - Name: gofakeit.Name(), + removeUser, err := Client.CreateUser(CTX, &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "givenName", + FamilyName: "familyName", + }, + Email: &user.SetHumanEmail{ + Email: gofakeit.Email(), + Verification: &user.SetHumanEmail_IsVerified{IsVerified: true}, + }, + }, + }, }) - request.UserId = removeUser.UserId require.NoError(t, err) - tokenResp, err := Instance.Client.Mgmt.AddPersonalAccessToken(CTX, &mgmt.AddPersonalAccessTokenRequest{UserId: removeUser.UserId}) - require.NoError(t, err) - return integration.WithAuthorizationToken(UserCTX, tokenResp.Token) + request.UserId = removeUser.Id + Instance.RegisterUserPasskey(CTX, removeUser.Id) + _, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, CTX, removeUser.Id) + return integration.WithAuthorizationToken(UserCTX, token) }, }, want: &user.DeleteUserResponse{ @@ -3610,7 +3621,6 @@ func TestServer_HumanMFAInitSkipped(t *testing.T) { t.Run(tt.name, func(t *testing.T) { err := tt.args.prepare(tt.args.req) require.NoError(t, err) - got, err := Client.HumanMFAInitSkipped(tt.args.ctx, tt.args.req) if tt.wantErr { require.Error(t, err) @@ -3624,3 +3634,1678 @@ func TestServer_HumanMFAInitSkipped(t *testing.T) { }) } } + +func TestServer_CreateUser(t *testing.T) { + type args struct { + ctx context.Context + req *user.CreateUserRequest + } + type testCase struct { + args args + want *user.CreateUserResponse + wantErr bool + } + tests := []struct { + name string + testCase func(runId string) testCase + }{ + { + name: "default verification", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + wantErr: false, + } + }, + }, + { + name: "return email verification code", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + EmailCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "return phone verification code", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + PhoneCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template error", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing REQUIRED profile", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing REQUIRED email", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing empty email", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{}, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing idp", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: "idpID", + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "with idp", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + idpResp := Instance.AddGenericOAuthProvider(IamCTX, Instance.DefaultOrg.Id) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + IdpLinks: []*user.IDPLink{ + { + IdpId: idpResp.Id, + UserId: "userID", + UserName: "username", + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "with totp", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + TotpSecret: gu.Ptr("secret"), + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "password not complexity conform", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + NickName: gu.Ptr("Dukkie"), + DisplayName: gu.Ptr("Donald Duck"), + PreferredLanguage: gu.Ptr("en"), + Gender: user.Gender_GENDER_DIVERSE.Enum(), + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + PasswordType: &user.CreateUserRequest_Human_Password{ + Password: &user.Password{ + Password: "insufficient", + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "hashed password", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + PasswordType: &user.CreateUserRequest_Human_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "unsupported hashed password", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + PasswordType: &user.CreateUserRequest_Human_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "human default username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "machine user", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "machine default username to generated id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, + { + name: "machine default username to given id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + UserId: &runId, + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: runId, + }, + } + }, + }, + { + name: "org does not exist human, error", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: "does not exist", + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "org does not exist machine, error", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: "does not exist", + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: gofakeit.Name(), + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + test := tt.testCase(runId) + got, err := Client.CreateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + if test.want.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode(), "email code is empty") + } else { + assert.Empty(t, got.GetEmailCode(), "email code is not empty") + } + if test.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode(), "phone code is empty") + } else { + assert.Empty(t, got.GetPhoneCode(), "phone code is not empty") + } + if test.want.GetId() == "is generated" { + assert.Len(t, got.GetId(), 18, "ID is not 18 characters") + } else { + assert.Equal(t, test.want.GetId(), got.GetId(), "ID is not the same") + } + }) + } +} + +func TestServer_CreateUser_And_Compare(t *testing.T) { + type args struct { + ctx context.Context + req *user.CreateUserRequest + } + type testCase struct { + name string + args args + assert func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) + } + tests := []struct { + name string + testCase func(runId string) testCase + }{{ + name: "human given username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "human username default to email", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, email, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username given", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username default to generated id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, createResponse.GetId(), getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username default to given id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: CTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, runId, getResponse.GetUser().GetUsername()) + }, + } + }, + }} + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + test := tt.testCase(runId) + createResponse, err := Client.CreateUser(test.args.ctx, test.args.req) + require.NoError(t, err) + Instance.TriggerUserByID(test.args.ctx, createResponse.GetId()) + getResponse, err := Client.GetUserByID(test.args.ctx, &user.GetUserByIDRequest{ + UserId: createResponse.GetId(), + }) + require.NoError(t, err) + test.assert(t, createResponse, getResponse) + }) + } +} + +func TestServer_CreateUser_Permission(t *testing.T) { + newOrgOwnerEmail := gofakeit.Email() + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) + type args struct { + ctx context.Context + req *user.CreateUserRequest + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "human system, ok", + args: args{ + SystemCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + }, + { + name: "human instance, ok", + args: args{ + IamCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + }, + { + name: "human org, error", + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "human user, error", + args: args{ + UserCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: "this is overwritten with a unique address", + }, + }, + }, + }, + }, + wantErr: true, + }, + { + name: "machine system, ok", + args: args{ + SystemCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + }, + { + name: "machine instance, ok", + args: args{ + IamCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + }, + { + name: "machine org, error", + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + wantErr: true, + }, + { + name: "machine user, error", + args: args{ + UserCTX, + &user.CreateUserRequest{ + OrganizationId: newOrg.GetOrganizationId(), + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + wantErr: true, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userID := fmt.Sprint(time.Now().UnixNano() + int64(i)) + tt.args.req.UserId = &userID + if email := tt.args.req.GetHuman().GetEmail(); email != nil { + email.Email = fmt.Sprintf("%s@example.com", userID) + } + _, err := Client.CreateUser(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +func TestServer_UpdateUserTypeHuman(t *testing.T) { + type args struct { + ctx context.Context + req *user.UpdateUserRequest + } + type testCase struct { + args args + want *user.UpdateUserResponse + wantErr bool + } + tests := []struct { + name string + testCase func(runId, userId string) testCase + }{ + { + name: "default verification", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{}, + wantErr: false, + } + }, + }, + { + name: "return email verification code", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{ + EmailCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}"), + }, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{}, + } + }, + }, + { + name: "return phone verification code", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{ + Phone: "+41791234568", + Verification: &user.SetHumanPhone_ReturnCode{ + ReturnCode: &user.ReturnPhoneVerificationCode{}, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{ + PhoneCode: gu.Ptr("something"), + }, + } + }, + }, + { + name: "custom template error", + testCase: func(runId, userId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_SendCode{ + SendCode: &user.SendEmailVerificationCode{ + UrlTemplate: gu.Ptr("{{"), + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "missing empty email", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Email: &user.SetHumanEmail{}, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "password not complexity conform", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_Password{ + Password: &user.Password{ + Password: "insufficient", + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "hashed password", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$2y$12$hXUrnqdq1RIIYZ2HPytIIe5lXdIvbhqrTvdPsSF7o.jFh817Z6lwm", + }, + }, + }, + }, + }, + }, + }, + want: &user.UpdateUserResponse{}, + } + }, + }, + { + name: "unsupported hashed password", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Password: &user.SetPassword{ + PasswordType: &user.SetPassword_HashedPassword{ + HashedPassword: &user.HashedPassword{ + Hash: "$scrypt$ln=16,r=8,p=1$cmFuZG9tc2FsdGlzaGFyZA$Rh+NnJNo1I6nRwaNqbDm6kmADswD1+7FTKZ7Ln9D8nQ", + }, + }, + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "update human user with machine fields, error", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: &runId, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + userId := Instance.CreateUserTypeHuman(CTX).GetId() + test := tt.testCase(runId, userId) + got, err := Client.UpdateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + assert.Less(t, changeDate, time.Now(), "change date is in the future") + if test.want.GetEmailCode() != "" { + assert.NotEmpty(t, got.GetEmailCode(), "email code is empty") + } else { + assert.Empty(t, got.GetEmailCode(), "email code is not empty") + } + if test.want.GetPhoneCode() != "" { + assert.NotEmpty(t, got.GetPhoneCode(), "phone code is empty") + } else { + assert.Empty(t, got.GetPhoneCode(), "phone code is not empty") + } + }) + } +} + +func TestServer_UpdateUserTypeMachine(t *testing.T) { + type args struct { + ctx context.Context + req *user.UpdateUserRequest + } + type testCase struct { + args args + wantErr bool + } + tests := []struct { + name string + testCase func(runId, userId string) testCase + }{ + { + name: "update machine, ok", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("donald"), + }, + }, + }, + }, + } + }, + }, + { + name: "update machine user with human fields, error", + testCase: func(runId, userId string) testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: userId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + userId := Instance.CreateUserTypeMachine(CTX).GetId() + test := tt.testCase(runId, userId) + got, err := Client.UpdateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + assert.Less(t, changeDate, time.Now(), "change date is in the future") + }) + } +} + +func TestServer_UpdateUser_And_Compare(t *testing.T) { + type args struct { + ctx context.Context + create *user.CreateUserRequest + update *user.UpdateUserRequest + } + type testCase struct { + args args + assert func(t *testing.T, getResponse *user.GetUserByIDResponse) + } + tests := []struct { + name string + testCase func(runId string) testCase + }{{ + name: "human remove phone", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + create: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + Phone: &user.SetHumanPhone{ + Phone: "+1234567890", + }, + }, + }, + }, + update: &user.UpdateUserRequest{ + UserId: runId, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Phone: &user.SetHumanPhone{}, + }, + }, + }, + }, + assert: func(t *testing.T, getResponse *user.GetUserByIDResponse) { + assert.Empty(t, getResponse.GetUser().GetHuman().GetPhone().GetPhone(), "phone is not empty") + }, + } + }, + }, { + name: "human username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: CTX, + create: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + }, + update: &user.UpdateUserRequest{ + UserId: runId, + Username: &username, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{}, + }, + }, + }, + assert: func(t *testing.T, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }, { + name: "machine username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + ctx: CTX, + create: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "Donald", + }, + }, + }, + update: &user.UpdateUserRequest{ + UserId: runId, + Username: &username, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{}, + }, + }, + }, + assert: func(t *testing.T, getResponse *user.GetUserByIDResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, + }} + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + runId := fmt.Sprint(now.UnixNano() + int64(i)) + test := tt.testCase(runId) + createResponse, err := Client.CreateUser(test.args.ctx, test.args.create) + require.NoError(t, err) + _, err = Client.UpdateUser(test.args.ctx, test.args.update) + require.NoError(t, err) + Instance.TriggerUserByID(test.args.ctx, createResponse.GetId()) + getResponse, err := Client.GetUserByID(test.args.ctx, &user.GetUserByIDRequest{ + UserId: createResponse.GetId(), + }) + require.NoError(t, err) + test.assert(t, getResponse) + }) + } +} + +func TestServer_UpdateUser_Permission(t *testing.T) { + newOrgOwnerEmail := gofakeit.Email() + newOrg := Instance.CreateOrganization(IamCTX, fmt.Sprintf("AddHuman-%s", gofakeit.AppName()), newOrgOwnerEmail) + newHumanUserID := newOrg.CreatedAdmins[0].GetUserId() + machineUserResp, err := Instance.Client.UserV2.CreateUser(IamCTX, &user.CreateUserRequest{ + OrganizationId: newOrg.OrganizationId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "Donald", + }, + }, + }) + require.NoError(t, err) + newMachineUserID := machineUserResp.GetId() + Instance.TriggerUserByID(IamCTX, newMachineUserID) + type args struct { + ctx context.Context + req *user.UpdateUserRequest + } + type testCase struct { + args args + wantErr bool + } + tests := []struct { + name string + testCase func() testCase + }{ + { + name: "human, system, ok", + testCase: func() testCase { + return testCase{ + args: args{ + SystemCTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + } + }, + }, + { + name: "human instance, ok", + testCase: func() testCase { + return testCase{ + args: args{ + IamCTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + } + }, + }, + { + name: "human org, error", + testCase: func() testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "human user, error", + testCase: func() testCase { + return testCase{ + args: args{ + UserCTX, + &user.UpdateUserRequest{ + UserId: newHumanUserID, + UserType: &user.UpdateUserRequest_Human_{ + Human: &user.UpdateUserRequest_Human{ + Profile: &user.UpdateUserRequest_Human_Profile{ + GivenName: gu.Ptr("Donald"), + }, + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "machine system, ok", + testCase: func() testCase { + return testCase{ + args: args{ + SystemCTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + } + }, + }, + { + name: "machine instance, ok", + testCase: func() testCase { + return testCase{ + args: args{ + IamCTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + } + }, + }, + { + name: "machine org, error", + testCase: func() testCase { + return testCase{ + args: args{ + CTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + wantErr: true, + } + }, + }, + { + name: "machine user, error", + testCase: func() testCase { + return testCase{ + args: args{ + UserCTX, + &user.UpdateUserRequest{ + UserId: newMachineUserID, + UserType: &user.UpdateUserRequest_Machine_{ + Machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("Donald"), + }, + }, + }, + }, + wantErr: true, + } + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test := tt.testCase() + _, err := Client.UpdateUser(test.args.ctx, test.args.req) + if test.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/internal/api/grpc/user/v2/key.go b/internal/api/grpc/user/v2/key.go new file mode 100644 index 0000000000..59dab44248 --- /dev/null +++ b/internal/api/grpc/user/v2/key.go @@ -0,0 +1,62 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) AddKey(ctx context.Context, req *user.AddKeyRequest) (*user.AddKeyResponse, error) { + newMachineKey := &command.MachineKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + ExpirationDate: req.GetExpirationDate().AsTime(), + Type: domain.AuthNKeyTypeJSON, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + } + newMachineKey.PublicKey = req.PublicKey + + pubkeySupplied := len(newMachineKey.PublicKey) > 0 + details, err := s.command.AddUserMachineKey(ctx, newMachineKey) + if err != nil { + return nil, err + } + // Return key details only if the pubkey wasn't supplied, otherwise the user already has + // private key locally + var keyDetails []byte + if !pubkeySupplied { + var err error + keyDetails, err = newMachineKey.Detail() + if err != nil { + return nil, err + } + } + return &user.AddKeyResponse{ + KeyId: newMachineKey.KeyID, + KeyContent: keyDetails, + CreationDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) RemoveKey(ctx context.Context, req *user.RemoveKeyRequest) (*user.RemoveKeyResponse, error) { + machineKey := &command.MachineKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + KeyID: req.KeyId, + } + objectDetails, err := s.command.RemoveUserMachineKey(ctx, machineKey) + if err != nil { + return nil, err + } + return &user.RemoveKeyResponse{ + DeletionDate: timestamppb.New(objectDetails.EventDate), + }, nil +} diff --git a/internal/api/grpc/user/v2/key_query.go b/internal/api/grpc/user/v2/key_query.go new file mode 100644 index 0000000000..da4f47decf --- /dev/null +++ b/internal/api/grpc/user/v2/key_query.go @@ -0,0 +1,124 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user.ListKeysResponse, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + + filters, err := keyFiltersToQueries(req.Filters) + if err != nil { + return nil, err + } + search := &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: authnKeyFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: filters, + } + result, err := s.query.SearchAuthNKeys(ctx, search, query.JoinFilterUserMachine, s.checkPermission) + if err != nil { + return nil, err + } + resp := &user.ListKeysResponse{ + Result: make([]*user.Key, len(result.AuthNKeys)), + Pagination: filter.QueryToPaginationPb(search.SearchRequest, result.SearchResponse), + } + for i, key := range result.AuthNKeys { + resp.Result[i] = &user.Key{ + CreationDate: timestamppb.New(key.CreationDate), + ChangeDate: timestamppb.New(key.ChangeDate), + Id: key.ID, + UserId: key.AggregateID, + OrganizationId: key.ResourceOwner, + ExpirationDate: timestamppb.New(key.Expiration), + } + } + return resp, nil +} + +func keyFiltersToQueries(filters []*user.KeysSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(filters)) + for i, filter := range filters { + q[i], err = keyFilterToQuery(filter) + if err != nil { + return nil, err + } + } + return q, nil +} + +func keyFilterToQuery(filter *user.KeysSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *user.KeysSearchFilter_CreatedDateFilter: + return authnKeyCreatedFilterToQuery(q.CreatedDateFilter) + case *user.KeysSearchFilter_ExpirationDateFilter: + return authnKeyExpirationFilterToQuery(q.ExpirationDateFilter) + case *user.KeysSearchFilter_KeyIdFilter: + return authnKeyIdFilterToQuery(q.KeyIdFilter) + case *user.KeysSearchFilter_UserIdFilter: + return authnKeyUserIdFilterToQuery(q.UserIdFilter) + case *user.KeysSearchFilter_OrganizationIdFilter: + return authnKeyOrgIdFilterToQuery(q.OrganizationIdFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func authnKeyIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyIDQuery(f.Id) +} + +func authnKeyUserIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyIdentifyerQuery(f.Id) +} + +func authnKeyOrgIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyResourceOwnerQuery(f.Id) +} + +func authnKeyCreatedFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyCreationDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +func authnKeyExpirationFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewAuthNKeyExpirationDateDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +// authnKeyFieldNameToSortingColumn defaults to the creation date because this ensures deterministic pagination +func authnKeyFieldNameToSortingColumn(field *user.KeyFieldName) query.Column { + if field == nil { + return query.AuthNKeyColumnCreationDate + } + switch *field { + case user.KeyFieldName_KEY_FIELD_NAME_UNSPECIFIED: + return query.AuthNKeyColumnCreationDate + case user.KeyFieldName_KEY_FIELD_NAME_ID: + return query.AuthNKeyColumnID + case user.KeyFieldName_KEY_FIELD_NAME_USER_ID: + return query.AuthNKeyColumnIdentifier + case user.KeyFieldName_KEY_FIELD_NAME_ORGANIZATION_ID: + return query.AuthNKeyColumnResourceOwner + case user.KeyFieldName_KEY_FIELD_NAME_CREATED_DATE: + return query.AuthNKeyColumnCreationDate + case user.KeyFieldName_KEY_FIELD_NAME_KEY_EXPIRATION_DATE: + return query.AuthNKeyColumnExpiration + default: + return query.AuthNKeyColumnCreationDate + } +} diff --git a/internal/api/grpc/user/v2/machine.go b/internal/api/grpc/user/v2/machine.go new file mode 100644 index 0000000000..010ba75678 --- /dev/null +++ b/internal/api/grpc/user/v2/machine.go @@ -0,0 +1,58 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*user.CreateUserResponse, error) { + cmd := &command.Machine{ + Username: userName, + Name: machinePb.Name, + Description: machinePb.GetDescription(), + AccessTokenType: domain.OIDCTokenTypeBearer, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: orgId, + AggregateID: userId, + }, + } + details, err := s.command.AddMachine( + ctx, + cmd, + s.command.NewPermissionCheckUserWrite(ctx), + command.AddMachineWithUsernameToIDFallback(), + ) + if err != nil { + return nil, err + } + return &user.CreateUserResponse{ + Id: cmd.AggregateID, + CreationDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) updateUserTypeMachine(ctx context.Context, machinePb *user.UpdateUserRequest_Machine, userId string, userName *string) (*user.UpdateUserResponse, error) { + cmd := updateMachineUserToCommand(userId, userName, machinePb) + err := s.command.ChangeUserMachine(ctx, cmd) + if err != nil { + return nil, err + } + return &user.UpdateUserResponse{ + ChangeDate: timestamppb.New(cmd.Details.EventDate), + }, nil +} + +func updateMachineUserToCommand(userId string, userName *string, machine *user.UpdateUserRequest_Machine) *command.ChangeMachine { + return &command.ChangeMachine{ + ID: userId, + Username: userName, + Name: machine.Name, + Description: machine.Description, + } +} diff --git a/internal/api/grpc/user/v2/machine_test.go b/internal/api/grpc/user/v2/machine_test.go new file mode 100644 index 0000000000..96d77d8fa2 --- /dev/null +++ b/internal/api/grpc/user/v2/machine_test.go @@ -0,0 +1,62 @@ +package user + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/muhlemmer/gu" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func Test_patchMachineUserToCommand(t *testing.T) { + type args struct { + userId string + userName *string + machine *user.UpdateUserRequest_Machine + } + tests := []struct { + name string + args args + want *command.ChangeMachine + }{{ + name: "single property", + args: args{ + userId: "userId", + machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("name"), + }, + }, + want: &command.ChangeMachine{ + ID: "userId", + Name: gu.Ptr("name"), + }, + }, { + name: "all properties", + args: args{ + userId: "userId", + userName: gu.Ptr("userName"), + machine: &user.UpdateUserRequest_Machine{ + Name: gu.Ptr("name"), + Description: gu.Ptr("description"), + }, + }, + want: &command.ChangeMachine{ + ID: "userId", + Username: gu.Ptr("userName"), + Name: gu.Ptr("name"), + Description: gu.Ptr("description"), + }, + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := updateMachineUserToCommand(tt.args.userId, tt.args.userName, tt.args.machine) + if diff := cmp.Diff(tt.want, got, cmpopts.EquateComparable(language.Tag{})); diff != "" { + t.Errorf("patchMachineUserToCommand() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/api/grpc/user/v2/pat.go b/internal/api/grpc/user/v2/pat.go new file mode 100644 index 0000000000..54f6e99367 --- /dev/null +++ b/internal/api/grpc/user/v2/pat.go @@ -0,0 +1,56 @@ +package user + +import ( + "context" + + "github.com/zitadel/oidc/v3/pkg/oidc" + "google.golang.org/protobuf/types/known/timestamppb" + + z_oidc "github.com/zitadel/zitadel/internal/api/oidc" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPersonalAccessTokenRequest) (*user.AddPersonalAccessTokenResponse, error) { + newPat := &command.PersonalAccessToken{ + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + ExpirationDate: req.ExpirationDate.AsTime(), + Scopes: []string{ + oidc.ScopeOpenID, + oidc.ScopeProfile, + z_oidc.ScopeUserMetaData, + z_oidc.ScopeResourceOwner, + }, + AllowedUserType: domain.UserTypeMachine, + } + details, err := s.command.AddPersonalAccessToken(ctx, newPat) + if err != nil { + return nil, err + } + return &user.AddPersonalAccessTokenResponse{ + CreationDate: timestamppb.New(details.EventDate), + TokenId: newPat.TokenID, + Token: newPat.Token, + }, nil +} + +func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *user.RemovePersonalAccessTokenRequest) (*user.RemovePersonalAccessTokenResponse, error) { + objectDetails, err := s.command.RemovePersonalAccessToken(ctx, &command.PersonalAccessToken{ + TokenID: req.TokenId, + ObjectRoot: models.ObjectRoot{ + AggregateID: req.UserId, + }, + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + }) + if err != nil { + return nil, err + } + return &user.RemovePersonalAccessTokenResponse{ + DeletionDate: timestamppb.New(objectDetails.EventDate), + }, nil +} diff --git a/internal/api/grpc/user/v2/pat_query.go b/internal/api/grpc/user/v2/pat_query.go new file mode 100644 index 0000000000..6bbd44d511 --- /dev/null +++ b/internal/api/grpc/user/v2/pat_query.go @@ -0,0 +1,123 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPersonalAccessTokensRequest) (*user.ListPersonalAccessTokensResponse, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + filters, err := patFiltersToQueries(req.Filters) + if err != nil { + return nil, err + } + search := &query.PersonalAccessTokenSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: filters, + } + result, err := s.query.SearchPersonalAccessTokens(ctx, search, s.checkPermission) + if err != nil { + return nil, err + } + resp := &user.ListPersonalAccessTokensResponse{ + Result: make([]*user.PersonalAccessToken, len(result.PersonalAccessTokens)), + Pagination: filter.QueryToPaginationPb(search.SearchRequest, result.SearchResponse), + } + for i, pat := range result.PersonalAccessTokens { + resp.Result[i] = &user.PersonalAccessToken{ + CreationDate: timestamppb.New(pat.CreationDate), + ChangeDate: timestamppb.New(pat.ChangeDate), + Id: pat.ID, + UserId: pat.UserID, + OrganizationId: pat.ResourceOwner, + ExpirationDate: timestamppb.New(pat.Expiration), + } + } + return resp, nil +} + +func patFiltersToQueries(filters []*user.PersonalAccessTokensSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(filters)) + for i, filter := range filters { + q[i], err = patFilterToQuery(filter) + if err != nil { + return nil, err + } + } + return q, nil +} + +func patFilterToQuery(filter *user.PersonalAccessTokensSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *user.PersonalAccessTokensSearchFilter_CreatedDateFilter: + return authnPersonalAccessTokenCreatedFilterToQuery(q.CreatedDateFilter) + case *user.PersonalAccessTokensSearchFilter_ExpirationDateFilter: + return authnPersonalAccessTokenExpirationFilterToQuery(q.ExpirationDateFilter) + case *user.PersonalAccessTokensSearchFilter_TokenIdFilter: + return authnPersonalAccessTokenIdFilterToQuery(q.TokenIdFilter) + case *user.PersonalAccessTokensSearchFilter_UserIdFilter: + return authnPersonalAccessTokenUserIdFilterToQuery(q.UserIdFilter) + case *user.PersonalAccessTokensSearchFilter_OrganizationIdFilter: + return authnPersonalAccessTokenOrgIdFilterToQuery(q.OrganizationIdFilter) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func authnPersonalAccessTokenIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenIDQuery(f.Id) +} + +func authnPersonalAccessTokenUserIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenUserIDSearchQuery(f.Id) +} + +func authnPersonalAccessTokenOrgIdFilterToQuery(f *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenResourceOwnerSearchQuery(f.Id) +} + +func authnPersonalAccessTokenCreatedFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenCreationDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +func authnPersonalAccessTokenExpirationFilterToQuery(f *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewPersonalAccessTokenExpirationDateDateQuery(f.Timestamp.AsTime(), filter.TimestampMethodPbToQuery(f.Method)) +} + +// authnPersonalAccessTokenFieldNameToSortingColumn defaults to the creation date because this ensures deterministic pagination +func authnPersonalAccessTokenFieldNameToSortingColumn(field *user.PersonalAccessTokenFieldName) query.Column { + if field == nil { + return query.PersonalAccessTokenColumnCreationDate + } + switch *field { + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_UNSPECIFIED: + return query.PersonalAccessTokenColumnCreationDate + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_ID: + return query.PersonalAccessTokenColumnID + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_USER_ID: + return query.PersonalAccessTokenColumnUserID + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_ORGANIZATION_ID: + return query.PersonalAccessTokenColumnResourceOwner + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE: + return query.PersonalAccessTokenColumnCreationDate + case user.PersonalAccessTokenFieldName_PERSONAL_ACCESS_TOKEN_FIELD_NAME_EXPIRATION_DATE: + return query.PersonalAccessTokenColumnExpiration + default: + return query.PersonalAccessTokenColumnCreationDate + } +} diff --git a/internal/api/grpc/user/v2/secret.go b/internal/api/grpc/user/v2/secret.go new file mode 100644 index 0000000000..1d54e1dde8 --- /dev/null +++ b/internal/api/grpc/user/v2/secret.go @@ -0,0 +1,39 @@ +package user + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) AddSecret(ctx context.Context, req *user.AddSecretRequest) (*user.AddSecretResponse, error) { + newSecret := &command.GenerateMachineSecret{ + PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), + } + details, err := s.command.GenerateMachineSecret(ctx, req.UserId, "", newSecret) + if err != nil { + return nil, err + } + return &user.AddSecretResponse{ + CreationDate: timestamppb.New(details.EventDate), + ClientSecret: newSecret.ClientSecret, + }, nil +} + +func (s *Server) RemoveSecret(ctx context.Context, req *user.RemoveSecretRequest) (*user.RemoveSecretResponse, error) { + details, err := s.command.RemoveMachineSecret( + ctx, + req.UserId, + "", + s.command.NewPermissionCheckUserWrite(ctx), + ) + if err != nil { + return nil, err + } + return &user.RemoveSecretResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index 9272ea27ee..e3c7e8011e 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" @@ -18,12 +19,13 @@ var _ user.UserServiceServer = (*Server)(nil) type Server struct { user.UnimplementedUserServiceServer - command *command.Commands - query *query.Queries - userCodeAlg crypto.EncryptionAlgorithm - idpAlg crypto.EncryptionAlgorithm - idpCallback func(ctx context.Context) string - samlRootURL func(ctx context.Context, idpID string) string + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + userCodeAlg crypto.EncryptionAlgorithm + idpAlg crypto.EncryptionAlgorithm + idpCallback func(ctx context.Context) string + samlRootURL func(ctx context.Context, idpID string) string assetAPIPrefix func(context.Context) string @@ -33,6 +35,7 @@ type Server struct { type Config struct{} func CreateServer( + systemDefaults systemdefaults.SystemDefaults, command *command.Commands, query *query.Queries, userCodeAlg crypto.EncryptionAlgorithm, @@ -43,6 +46,7 @@ func CreateServer( checkPermission domain.PermissionCheck, ) *Server { return &Server{ + systemDefaults: systemDefaults, command: command, query: query, userCodeAlg: userCodeAlg, diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 0f958f0d40..6b4b2da75b 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -11,6 +11,7 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) @@ -117,7 +118,7 @@ func genderToDomain(gender user.Gender) domain.Gender { } func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := UpdateUserRequestToChangeHuman(req) + human, err := updateHumanUserRequestToChangeHuman(req) if err != nil { return nil, err } @@ -181,86 +182,6 @@ func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { return &pVal } -func UpdateUserRequestToChangeHuman(req *user.UpdateHumanUserRequest) (*command.ChangeHuman, error) { - email, err := SetHumanEmailToEmail(req.Email, req.GetUserId()) - if err != nil { - return nil, err - } - return &command.ChangeHuman{ - ID: req.GetUserId(), - Username: req.Username, - Profile: SetHumanProfileToProfile(req.Profile), - Email: email, - Phone: SetHumanPhoneToPhone(req.Phone), - Password: SetHumanPasswordToPassword(req.Password), - }, nil -} - -func SetHumanProfileToProfile(profile *user.SetHumanProfile) *command.Profile { - if profile == nil { - return nil - } - var firstName *string - if profile.GivenName != "" { - firstName = &profile.GivenName - } - var lastName *string - if profile.FamilyName != "" { - lastName = &profile.FamilyName - } - return &command.Profile{ - FirstName: firstName, - LastName: lastName, - NickName: profile.NickName, - DisplayName: profile.DisplayName, - PreferredLanguage: ifNotNilPtr(profile.PreferredLanguage, language.Make), - Gender: ifNotNilPtr(profile.Gender, genderToDomain), - } -} - -func SetHumanEmailToEmail(email *user.SetHumanEmail, userID string) (*command.Email, error) { - if email == nil { - return nil, nil - } - var urlTemplate string - if email.GetSendCode() != nil && email.GetSendCode().UrlTemplate != nil { - urlTemplate = *email.GetSendCode().UrlTemplate - if err := domain.RenderConfirmURLTemplate(io.Discard, urlTemplate, userID, "code", "orgID"); err != nil { - return nil, err - } - } - return &command.Email{ - Address: domain.EmailAddress(email.Email), - Verified: email.GetIsVerified(), - ReturnCode: email.GetReturnCode() != nil, - URLTemplate: urlTemplate, - }, nil -} - -func SetHumanPhoneToPhone(phone *user.SetHumanPhone) *command.Phone { - if phone == nil { - return nil - } - return &command.Phone{ - Number: domain.PhoneNumber(phone.GetPhone()), - Verified: phone.GetIsVerified(), - ReturnCode: phone.GetReturnCode() != nil, - } -} - -func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { - if password == nil { - return nil - } - return &command.Password{ - PasswordCode: password.GetVerificationCode(), - OldPassword: password.GetCurrentPassword(), - Password: password.GetPassword().GetPassword(), - EncodedPasswordHash: password.GetHashedPassword().GetHash(), - ChangeRequired: password.GetPassword().GetChangeRequired() || password.GetHashedPassword().GetChangeRequired(), - } -} - func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) if err != nil { @@ -482,3 +403,25 @@ func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *user.HumanMFAInit Details: object.DomainToDetailsPb(details), }, nil } + +func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.CreateUserResponse, error) { + switch userType := req.GetUserType().(type) { + case *user.CreateUserRequest_Human_: + return s.createUserTypeHuman(ctx, userType.Human, req.OrganizationId, req.Username, req.UserId) + case *user.CreateUserRequest_Machine_: + return s.createUserTypeMachine(ctx, userType.Machine, req.OrganizationId, req.GetUsername(), req.GetUserId()) + default: + return nil, zerrors.ThrowInternal(nil, "", "user type is not implemented") + } +} + +func (s *Server) UpdateUser(ctx context.Context, req *user.UpdateUserRequest) (*user.UpdateUserResponse, error) { + switch userType := req.GetUserType().(type) { + case *user.UpdateUserRequest_Human_: + return s.updateUserTypeHuman(ctx, userType.Human, req.UserId, req.Username) + case *user.UpdateUserRequest_Machine_: + return s.updateUserTypeMachine(ctx, userType.Machine, req.UserId, req.Username) + default: + return nil, zerrors.ThrowUnimplemented(nil, "", "user type is not implemented") + } +} diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/user_query.go similarity index 100% rename from internal/api/grpc/user/v2/query.go rename to internal/api/grpc/user/v2/user_query.go diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index bc8d864994..13baed5d51 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -203,7 +203,6 @@ func (h *UsersHandler) Delete(ctx context.Context, id string) error { if err != nil { return err } - _, err = h.command.RemoveUserV2(ctx, id, authz.GetCtxData(ctx).OrgID, memberships, grants...) return err } diff --git a/internal/command/instance_member.go b/internal/command/instance_member.go index ee9bf15f84..a33635e8f5 100644 --- a/internal/command/instance_member.go +++ b/internal/command/instance_member.go @@ -22,7 +22,7 @@ func (c *Commands) AddInstanceMemberCommand(a *instance.Aggregate, userID string return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-4m0fS", "Errors.IAM.MemberInvalid") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - if exists, err := ExistsUser(ctx, filter, userID, ""); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "INSTA-GSXOn", "Errors.User.NotFound") } if isMember, err := IsInstanceMember(ctx, filter, a.ID, userID); err != nil || isMember { diff --git a/internal/command/org_member.go b/internal/command/org_member.go index ae9bef2151..bf1ae91d8a 100644 --- a/internal/command/org_member.go +++ b/internal/command/org_member.go @@ -28,7 +28,7 @@ func (c *Commands) AddOrgMemberCommand(a *org.Aggregate, userID string, roles .. ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if exists, err := ExistsUser(ctx, filter, userID, ""); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "ORG-GoXOn", "Errors.User.NotFound") } if isMember, err := IsOrgMember(ctx, filter, a.ID, userID); err != nil || isMember { diff --git a/internal/command/resource_ower_model.go b/internal/command/resource_owner_model.go similarity index 100% rename from internal/command/resource_ower_model.go rename to internal/command/resource_owner_model.go diff --git a/internal/command/user.go b/internal/command/user.go index 6b65aa83ec..0db4fda328 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -353,21 +353,27 @@ func (c *Commands) userWriteModelByID(ctx context.Context, userID, resourceOwner return writeModel, nil } -func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string) (exists bool, err error) { +func ExistsUser(ctx context.Context, filter preparation.FilterToQueryReducer, id, resourceOwner string, machineOnly bool) (exists bool, err error) { + eventTypes := []eventstore.EventType{ + user.MachineAddedEventType, + user.UserRemovedType, + } + if !machineOnly { + eventTypes = append(eventTypes, + user.HumanRegisteredType, + user.UserV1RegisteredType, + user.HumanAddedType, + user.UserV1AddedType, + ) + } events, err := filter(ctx, eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). ResourceOwner(resourceOwner). OrderAsc(). AddQuery(). AggregateTypes(user.AggregateType). AggregateIDs(id). - EventTypes( - user.HumanRegisteredType, - user.UserV1RegisteredType, - user.HumanAddedType, - user.UserV1AddedType, - user.MachineAddedEventType, - user.UserRemovedType, - ).Builder()) + EventTypes(eventTypes...). + Builder()) if err != nil { return false, err } diff --git a/internal/command/user_machine.go b/internal/command/user_machine.go index 1ec32450ac..7c8fd89eac 100644 --- a/internal/command/user_machine.go +++ b/internal/command/user_machine.go @@ -25,6 +25,7 @@ type Machine struct { Name string Description string AccessTokenType domain.OIDCTokenType + PermissionCheck PermissionCheck } func (m *Machine) IsZero() bool { @@ -33,8 +34,8 @@ func (m *Machine) IsZero() bool { func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown2", "Errors.ResourceOwnerMissing") + if a.ResourceOwner == "" && machine.PermissionCheck == nil { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown3", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p2mi", "Errors.User.UserIDMissing") @@ -49,7 +50,7 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, machine.PermissionCheck) if err != nil { return nil, err } @@ -67,7 +68,18 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati } } -func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain.ObjectDetails, err error) { +type addMachineOption func(context.Context, *Machine) error + +func AddMachineWithUsernameToIDFallback() addMachineOption { + return func(ctx context.Context, m *Machine) error { + if m.Username == "" { + m.Username = m.AggregateID + } + return nil + } +} + +func (c *Commands) AddMachine(ctx context.Context, machine *Machine, check PermissionCheck, options ...addMachineOption) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -80,6 +92,16 @@ func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain. } agg := user.NewAggregate(machine.AggregateID, machine.ResourceOwner) + for _, option := range options { + if err = option(ctx, machine); err != nil { + return nil, err + } + } + if check != nil { + if err = check(machine.ResourceOwner, machine.AggregateID); err != nil { + return nil, err + } + } cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, AddMachineCommand(agg, machine)) if err != nil { return nil, err @@ -97,6 +119,7 @@ func (c *Commands) AddMachine(ctx context.Context, machine *Machine) (_ *domain. }, nil } +// Deprecated: use ChangeUserMachine instead func (c *Commands) ChangeMachine(ctx context.Context, machine *Machine) (*domain.ObjectDetails, error) { agg := user.NewAggregate(machine.AggregateID, machine.ResourceOwner) cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, changeMachineCommand(agg, machine)) @@ -118,24 +141,21 @@ func (c *Commands) ChangeMachine(ctx context.Context, machine *Machine) (*domain func changeMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { + if a.ResourceOwner == "" && machine.PermissionCheck == nil { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-xiown3", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p3mi", "Errors.User.UserIDMissing") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, machine.PermissionCheck) if err != nil { return nil, err } if !isUserStateExists(writeModel.UserState) { return nil, zerrors.ThrowNotFound(nil, "COMMAND-5M0od", "Errors.User.NotFound") } - changedEvent, hasChanged, err := writeModel.NewChangedEvent(ctx, &a.Aggregate, machine.Name, machine.Description, machine.AccessTokenType) - if err != nil { - return nil, err - } + changedEvent, hasChanged := writeModel.NewChangedEvent(ctx, &a.Aggregate, machine.Name, machine.Description, machine.AccessTokenType) if !hasChanged { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2n8vs", "Errors.User.NotChanged") } @@ -147,10 +167,9 @@ func changeMachineCommand(a *user.Aggregate, machine *Machine) preparation.Valid } } -func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, filter preparation.FilterToQueryReducer) (_ *MachineWriteModel, err error) { +func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, filter preparation.FilterToQueryReducer, permissionCheck PermissionCheck) (_ *MachineWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel := NewMachineWriteModel(userID, resourceOwner) events, err := filter(ctx, writeModel.Query()) if err != nil { @@ -161,5 +180,10 @@ func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, fil } writeModel.AppendEvents(events...) err = writeModel.Reduce() + if permissionCheck != nil { + if err := permissionCheck(writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + } return writeModel, err } diff --git a/internal/command/user_machine_key.go b/internal/command/user_machine_key.go index 8a0f0f437b..d628bf4c2d 100644 --- a/internal/command/user_machine_key.go +++ b/internal/command/user_machine_key.go @@ -15,12 +15,14 @@ import ( ) type AddMachineKey struct { - Type domain.AuthNKeyType - ExpirationDate time.Time + Type domain.AuthNKeyType + ExpirationDate time.Time + PermissionCheck PermissionCheck } type MachineKey struct { models.ObjectRoot + PermissionCheck PermissionCheck KeyID string Type domain.AuthNKeyType @@ -64,7 +66,7 @@ func (key *MachineKey) Detail() ([]byte, error) { } func (key *MachineKey) content() error { - if key.ResourceOwner == "" { + if key.PermissionCheck == nil && key.ResourceOwner == "" { return zerrors.ThrowInvalidArgument(nil, "COMMAND-kqpoix", "Errors.ResourceOwnerMissing") } if key.AggregateID == "" { @@ -91,7 +93,7 @@ func (key *MachineKey) valid() (err error) { } func (key *MachineKey) checkAggregate(ctx context.Context, filter preparation.FilterToQueryReducer) error { - if exists, err := ExistsUser(ctx, filter, key.AggregateID, key.ResourceOwner); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, key.AggregateID, key.ResourceOwner, true); err != nil || !exists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-bnipwm1", "Errors.User.NotFound") } return nil @@ -142,7 +144,7 @@ func prepareAddUserMachineKey(machineKey *MachineKey, keySize int) preparation.V return nil, err } } - writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner) + writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner, machineKey.PermissionCheck) if err != nil { return nil, err } @@ -186,7 +188,7 @@ func prepareRemoveUserMachineKey(machineKey *MachineKey) preparation.Validation return nil, err } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner) + writeModel, err := getMachineKeyWriteModelByID(ctx, filter, machineKey.AggregateID, machineKey.KeyID, machineKey.ResourceOwner, machineKey.PermissionCheck) if err != nil { return nil, err } @@ -204,16 +206,18 @@ func prepareRemoveUserMachineKey(machineKey *MachineKey) preparation.Validation } } -func getMachineKeyWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, keyID, resourceOwner string) (_ *MachineKeyWriteModel, err error) { +func getMachineKeyWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, keyID, resourceOwner string, permissionCheck PermissionCheck) (_ *MachineKeyWriteModel, err error) { writeModel := NewMachineKeyWriteModel(userID, keyID, resourceOwner) events, err := filter(ctx, writeModel.Query()) if err != nil { return nil, err } - if len(events) == 0 { - return writeModel, nil - } writeModel.AppendEvents(events...) err = writeModel.Reduce() + if permissionCheck != nil { + if err := permissionCheck(writeModel.ResourceOwner, writeModel.AggregateID); err != nil { + return nil, err + } + } return writeModel, err } diff --git a/internal/command/user_machine_model.go b/internal/command/user_machine_model.go index b7dfb02d32..1ed6c8ca58 100644 --- a/internal/command/user_machine_model.go +++ b/internal/command/user_machine_model.go @@ -2,7 +2,6 @@ package command import ( "context" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -106,9 +105,8 @@ func (wm *MachineWriteModel) NewChangedEvent( name, description string, accessTokenType domain.OIDCTokenType, -) (*user.MachineChangedEvent, bool, error) { +) (*user.MachineChangedEvent, bool) { changes := make([]user.MachineChanges, 0) - var err error if wm.Name != name { changes = append(changes, user.ChangeName(name)) @@ -120,11 +118,8 @@ func (wm *MachineWriteModel) NewChangedEvent( changes = append(changes, user.ChangeAccessTokenType(accessTokenType)) } if len(changes) == 0 { - return nil, false, nil + return nil, false } - changeEvent, err := user.NewMachineChangedEvent(ctx, aggregate, changes) - if err != nil { - return nil, false, err - } - return changeEvent, true, nil + changeEvent := user.NewMachineChangedEvent(ctx, aggregate, changes) + return changeEvent, true } diff --git a/internal/command/user_machine_secret.go b/internal/command/user_machine_secret.go index 3349fc90a5..34e9c0c5cc 100644 --- a/internal/command/user_machine_secret.go +++ b/internal/command/user_machine_secret.go @@ -11,7 +11,8 @@ import ( ) type GenerateMachineSecret struct { - ClientSecret string + PermissionCheck PermissionCheck + ClientSecret string } func (c *Commands) GenerateMachineSecret(ctx context.Context, userID string, resourceOwner string, set *GenerateMachineSecret) (*domain.ObjectDetails, error) { @@ -35,14 +36,14 @@ func (c *Commands) GenerateMachineSecret(ctx context.Context, userID string, res func (c *Commands) prepareGenerateMachineSecret(a *user.Aggregate, set *GenerateMachineSecret) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x0992n", "Errors.ResourceOwnerMissing") + if a.ResourceOwner == "" && set.PermissionCheck == nil { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0qp2hus", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bzoqjs", "Errors.User.UserIDMissing") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, set.PermissionCheck) if err != nil { return nil, err } @@ -62,9 +63,10 @@ func (c *Commands) prepareGenerateMachineSecret(a *user.Aggregate, set *Generate } } -func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resourceOwner string, permissionCheck PermissionCheck) (*domain.ObjectDetails, error) { agg := user.NewAggregate(userID, resourceOwner) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareRemoveMachineSecret(agg)) + //nolint:staticcheck + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, prepareRemoveMachineSecret(agg, permissionCheck)) if err != nil { return nil, err } @@ -81,16 +83,16 @@ func (c *Commands) RemoveMachineSecret(ctx context.Context, userID string, resou }, nil } -func prepareRemoveMachineSecret(a *user.Aggregate) preparation.Validation { +func prepareRemoveMachineSecret(a *user.Aggregate, check PermissionCheck) preparation.Validation { return func() (_ preparation.CreateCommands, err error) { - if a.ResourceOwner == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-0qp2hus", "Errors.ResourceOwnerMissing") + if a.ResourceOwner == "" && check == nil { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-x0992n", "Errors.ResourceOwnerMissing") } if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bzosjs", "Errors.User.UserIDMissing") } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { - writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter) + writeModel, err := getMachineWriteModel(ctx, a.ID, a.ResourceOwner, filter, check) if err != nil { return nil, err } diff --git a/internal/command/user_machine_secret_test.go b/internal/command/user_machine_secret_test.go index 4c6d16960c..8e839efe07 100644 --- a/internal/command/user_machine_secret_test.go +++ b/internal/command/user_machine_secret_test.go @@ -44,7 +44,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) { ctx: context.Background(), userID: "", resourceOwner: "org1", - set: nil, + set: new(GenerateMachineSecret), }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -59,7 +59,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) { ctx: context.Background(), userID: "user1", resourceOwner: "", - set: nil, + set: new(GenerateMachineSecret), }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -76,7 +76,7 @@ func TestCommandSide_GenerateMachineSecret(t *testing.T) { ctx: context.Background(), userID: "user1", resourceOwner: "org1", - set: nil, + set: new(GenerateMachineSecret), }, res: res{ err: zerrors.IsPreconditionFailed, @@ -289,7 +289,7 @@ func TestCommandSide_RemoveMachineSecret(t *testing.T) { r := &Commands{ eventstore: tt.fields.eventstore, } - got, err := r.RemoveMachineSecret(tt.args.ctx, tt.args.userID, tt.args.resourceOwner) + got, err := r.RemoveMachineSecret(tt.args.ctx, tt.args.userID, tt.args.resourceOwner, nil) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user_machine_test.go b/internal/command/user_machine_test.go index c7b4b8caf4..19548ae9c6 100644 --- a/internal/command/user_machine_test.go +++ b/internal/command/user_machine_test.go @@ -24,6 +24,8 @@ func TestCommandSide_AddMachine(t *testing.T) { type args struct { ctx context.Context machine *Machine + check PermissionCheck + options func(*Commands) []addMachineOption } type res struct { want *domain.ObjectDetails @@ -194,14 +196,242 @@ func TestCommandSide_AddMachine(t *testing.T) { }, }, }, + { + name: "with username fallback to given username", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "aggregateID"), + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + "username", + "name", + "", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "name", + Username: "username", + }, + options: func(commands *Commands) []addMachineOption { + return []addMachineOption{ + AddMachineWithUsernameToIDFallback(), + } + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with username fallback to generated id", + fields: fields{ + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "aggregateID"), + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + "aggregateID", + "name", + "", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Name: "name", + }, + options: func(commands *Commands) []addMachineOption { + return []addMachineOption{ + AddMachineWithUsernameToIDFallback(), + } + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with username fallback to given id", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("aggregateID", "org1").Aggregate, + "aggregateID", + "name", + "", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + AggregateID: "aggregateID", + }, + Name: "name", + }, + options: func(commands *Commands) []addMachineOption { + return []addMachineOption{ + AddMachineWithUsernameToIDFallback(), + } + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with succeeding permission check, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + }, + check: func(resourceOwner, aggregateID string) error { + return nil + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with failing permission check, error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + }, + check: func(resourceOwner, aggregateID string) error { + return zerrors.ThrowPermissionDenied(nil, "", "") + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.AddMachine(tt.args.ctx, tt.args.machine) + var options []addMachineOption + if tt.args.options != nil { + options = tt.args.options(r) + } + got, err := r.AddMachine(tt.args.ctx, tt.args.machine, tt.args.check, options...) if tt.res.err == nil { assert.NoError(t, err) } @@ -391,7 +621,7 @@ func TestCommandSide_ChangeMachine(t *testing.T) { } func newMachineChangedEvent(ctx context.Context, userID, resourceOwner, name, description string) *user.MachineChangedEvent { - event, _ := user.NewMachineChangedEvent(ctx, + event := user.NewMachineChangedEvent(ctx, &user.NewAggregate(userID, resourceOwner).Aggregate, []user.MachineChanges{ user.ChangeName(name), diff --git a/internal/command/user_personal_access_token.go b/internal/command/user_personal_access_token.go index 0faf85d5eb..f37953f3d6 100644 --- a/internal/command/user_personal_access_token.go +++ b/internal/command/user_personal_access_token.go @@ -21,6 +21,7 @@ type AddPat struct { type PersonalAccessToken struct { models.ObjectRoot + PermissionCheck PermissionCheck ExpirationDate time.Time Scopes []string @@ -43,7 +44,7 @@ func NewPersonalAccessToken(resourceOwner string, userID string, expirationDate } func (pat *PersonalAccessToken) content() error { - if pat.ResourceOwner == "" { + if pat.ResourceOwner == "" && pat.PermissionCheck == nil { return zerrors.ThrowInvalidArgument(nil, "COMMAND-xs0k2n", "Errors.ResourceOwnerMissing") } if pat.AggregateID == "" { @@ -109,11 +110,10 @@ func prepareAddPersonalAccessToken(pat *PersonalAccessToken, algorithm crypto.En if err := pat.checkAggregate(ctx, filter); err != nil { return nil, err } - writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner) + writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner, pat.PermissionCheck) if err != nil { return nil, err } - pat.Token, err = createToken(algorithm, writeModel.TokenID, writeModel.AggregateID) if err != nil { return nil, err @@ -155,7 +155,7 @@ func prepareRemovePersonalAccessToken(pat *PersonalAccessToken) preparation.Vali return nil, err } return func(ctx context.Context, filter preparation.FilterToQueryReducer) (_ []eventstore.Command, err error) { - writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner) + writeModel, err := getPersonalAccessTokenWriteModelByID(ctx, filter, pat.AggregateID, pat.TokenID, pat.ResourceOwner, pat.PermissionCheck) if err != nil { return nil, err } @@ -181,16 +181,18 @@ func createToken(algorithm crypto.EncryptionAlgorithm, tokenID, userID string) ( return base64.RawURLEncoding.EncodeToString(encrypted), nil } -func getPersonalAccessTokenWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, tokenID, resourceOwner string) (_ *PersonalAccessTokenWriteModel, err error) { +func getPersonalAccessTokenWriteModelByID(ctx context.Context, filter preparation.FilterToQueryReducer, userID, tokenID, resourceOwner string, check PermissionCheck) (_ *PersonalAccessTokenWriteModel, err error) { writeModel := NewPersonalAccessTokenWriteModel(userID, tokenID, resourceOwner) events, err := filter(ctx, writeModel.Query()) if err != nil { return nil, err } - if len(events) == 0 { - return writeModel, nil - } writeModel.AppendEvents(events...) - err = writeModel.Reduce() + if err = writeModel.Reduce(); err != nil { + return nil, err + } + if check != nil { + err = check(writeModel.ResourceOwner, writeModel.AggregateID) + } return writeModel, err } diff --git a/internal/command/user_test.go b/internal/command/user_test.go index 9abae187c1..6a1597fc8b 100644 --- a/internal/command/user_test.go +++ b/internal/command/user_test.go @@ -1813,7 +1813,7 @@ func TestExistsUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotExists, err := ExistsUser(context.Background(), tt.args.filter, tt.args.id, tt.args.resourceOwner) + gotExists, err := ExistsUser(context.Background(), tt.args.filter, tt.args.id, tt.args.resourceOwner, false) if (err != nil) != tt.wantErr { t.Errorf("ExistsUser() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index 5f8e8d6ff5..be10fd03fe 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -132,7 +132,6 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-vaipl7s13l", "Errors.User.UserIDMissing") } - existingUser, err := c.userRemoveWriteModel(ctx, userID, resourceOwner) if err != nil { return nil, err @@ -143,7 +142,6 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin if err := c.checkPermissionDeleteUser(ctx, existingUser.ResourceOwner, existingUser.AggregateID); err != nil { return nil, err } - domainPolicy, err := c.domainPolicyWriteModel(ctx, existingUser.ResourceOwner) if err != nil { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-l40ykb3xh2", "Errors.Org.DomainPolicy.NotExisting") diff --git a/internal/command/user_v2_human.go b/internal/command/user_v2_human.go index f88e2017d5..0945ae7578 100644 --- a/internal/command/user_v2_human.go +++ b/internal/command/user_v2_human.go @@ -5,6 +5,7 @@ import ( "golang.org/x/text/language" + "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -121,7 +122,10 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if resourceOwner == "" { return zerrors.ThrowInvalidArgument(nil, "COMMA-095xh8fll1", "Errors.Internal") } - + if human.Details == nil { + human.Details = &domain.ObjectDetails{} + } + human.Details.ResourceOwner = resourceOwner if err := human.Validate(c.userPasswordHasher); err != nil { return err } @@ -132,7 +136,12 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human return err } } - + // check for permission to create user on resourceOwner + if !human.Register { + if err := c.checkPermissionUpdateUser(ctx, resourceOwner, human.ID); err != nil { + return err + } + } // only check if user is already existing existingHuman, err := c.userExistsWriteModel( ctx, @@ -144,12 +153,6 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if isUserStateExists(existingHuman.UserState) { return zerrors.ThrowPreconditionFailed(nil, "COMMAND-7yiox1isql", "Errors.User.AlreadyExisting") } - // check for permission to create user on resourceOwner - if !human.Register { - if err := c.checkPermission(ctx, domain.PermissionUserWrite, resourceOwner, human.ID); err != nil { - return err - } - } // add resourceowner for the events with the aggregate existingHuman.ResourceOwner = resourceOwner @@ -161,6 +164,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if err = c.userValidateDomain(ctx, resourceOwner, human.Username, domainPolicy.UserLoginMustBeDomain); err != nil { return err } + var createCmd humanCreationCommand if human.Register { createCmd = user.NewHumanRegisteredEvent( @@ -203,17 +207,33 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human return err } - cmds := make([]eventstore.Command, 0, 3) - cmds = append(cmds, createCmd) - - cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, existingHuman.Aggregate(), human, alg, allowInitMail) + cmds, err := c.addUserHumanCommands(ctx, filter, existingHuman, human, allowInitMail, alg, createCmd) if err != nil { return err } + if len(cmds) == 0 { + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil + } + err = c.pushAppendAndReduce(ctx, existingHuman, cmds...) + if err != nil { + return err + } + human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) + return nil +} + +func (c *Commands) addUserHumanCommands(ctx context.Context, filter preparation.FilterToQueryReducer, existingHuman *UserV2WriteModel, human *AddHuman, allowInitMail bool, alg crypto.EncryptionAlgorithm, addUserCommand eventstore.Command) ([]eventstore.Command, error) { + cmds := []eventstore.Command{addUserCommand} + var err error + cmds, err = c.addHumanCommandEmail(ctx, filter, cmds, existingHuman.Aggregate(), human, alg, allowInitMail) + if err != nil { + return nil, err + } cmds, err = c.addHumanCommandPhone(ctx, filter, cmds, existingHuman.Aggregate(), human, alg) if err != nil { - return err + return nil, err } for _, metadataEntry := range human.Metadata { @@ -227,7 +247,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human for _, link := range human.Links { cmd, err := addLink(ctx, filter, existingHuman.Aggregate(), link) if err != nil { - return err + return nil, err } cmds = append(cmds, cmd) } @@ -235,7 +255,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if human.TOTPSecret != "" { encryptedSecret, err := crypto.Encrypt([]byte(human.TOTPSecret), c.multifactors.OTP.CryptoMFA) if err != nil { - return err + return nil, err } cmds = append(cmds, user.NewHumanOTPAddedEvent(ctx, &existingHuman.Aggregate().Aggregate, encryptedSecret), @@ -246,18 +266,7 @@ func (c *Commands) AddUserHuman(ctx context.Context, resourceOwner string, human if human.SetInactive { cmds = append(cmds, user.NewUserDeactivatedEvent(ctx, &existingHuman.Aggregate().Aggregate)) } - - if len(cmds) == 0 { - human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) - return nil - } - - err = c.pushAppendAndReduce(ctx, existingHuman, cmds...) - if err != nil { - return err - } - human.Details = writeModelToObjectDetails(&existingHuman.WriteModel) - return nil + return cmds, nil } func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg crypto.EncryptionAlgorithm) (err error) { @@ -341,7 +350,6 @@ func (c *Commands) ChangeUserHuman(ctx context.Context, human *ChangeHuman, alg if human.State != nil { // only allow toggling between active and inactive // any other target state is not supported - // the existing human's state has to be the switch { case isUserStateActive(*human.State): if isUserStateActive(existingHuman.UserState) { diff --git a/internal/command/user_v2_human_test.go b/internal/command/user_v2_human_test.go index 2b4399fb2a..e44e182b92 100644 --- a/internal/command/user_v2_human_test.go +++ b/internal/command/user_v2_human_test.go @@ -302,9 +302,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { { name: "add human (with initial code), no permission", fields: fields{ - eventstore: expectEventstore( - expectFilter(), - ), + eventstore: expectEventstore(), checkPermission: newMockPermissionCheckNotAllowed(), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), newCode: mockEncryptedCode("userinit", time.Hour), @@ -326,9 +324,7 @@ func TestCommandSide_AddUserHuman(t *testing.T) { codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, res: res{ - err: func(err error) bool { - return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) - }, + err: zerrors.IsPermissionDenied, }, }, { diff --git a/internal/command/user_v2_invite_test.go b/internal/command/user_v2_invite_test.go index 04c00d876e..75bd3157db 100644 --- a/internal/command/user_v2_invite_test.go +++ b/internal/command/user_v2_invite_test.go @@ -352,6 +352,7 @@ func TestCommands_ResendInviteCode(t *testing.T) { "user does not exist", fields{ eventstore: expectEventstore( + // The write model doesn't query any events expectFilter(), ), checkPermission: newMockPermissionCheckAllowed(), diff --git a/internal/command/user_v2_machine.go b/internal/command/user_v2_machine.go new file mode 100644 index 0000000000..34079b7e6f --- /dev/null +++ b/internal/command/user_v2_machine.go @@ -0,0 +1,94 @@ +package command + +import ( + "context" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type ChangeMachine struct { + ID string + ResourceOwner string + Username *string + Name *string + Description *string + + // Details are set after a successful execution of the command + Details *domain.ObjectDetails +} + +func (h *ChangeMachine) Changed() bool { + if h.Username != nil { + return true + } + if h.Name != nil { + return true + } + if h.Description != nil { + return true + } + return false +} + +func (c *Commands) ChangeUserMachine(ctx context.Context, machine *ChangeMachine) (err error) { + existingMachine, err := c.UserMachineWriteModel( + ctx, + machine.ID, + machine.ResourceOwner, + false, + ) + if err != nil { + return err + } + if machine.Changed() { + if err := c.checkPermissionUpdateUser(ctx, existingMachine.ResourceOwner, existingMachine.AggregateID); err != nil { + return err + } + } + + cmds := make([]eventstore.Command, 0) + if machine.Username != nil { + cmds, err = c.changeUsername(ctx, cmds, existingMachine, *machine.Username) + if err != nil { + return err + } + } + var machineChanges []user.MachineChanges + if machine.Name != nil && *machine.Name != existingMachine.Name { + machineChanges = append(machineChanges, user.ChangeName(*machine.Name)) + } + if machine.Description != nil && *machine.Description != existingMachine.Description { + machineChanges = append(machineChanges, user.ChangeDescription(*machine.Description)) + } + if len(machineChanges) > 0 { + cmds = append(cmds, user.NewMachineChangedEvent(ctx, &existingMachine.Aggregate().Aggregate, machineChanges)) + } + if len(cmds) == 0 { + machine.Details = writeModelToObjectDetails(&existingMachine.WriteModel) + return nil + } + err = c.pushAppendAndReduce(ctx, existingMachine, cmds...) + if err != nil { + return err + } + machine.Details = writeModelToObjectDetails(&existingMachine.WriteModel) + return nil +} + +func (c *Commands) UserMachineWriteModel(ctx context.Context, userID, resourceOwner string, metadataWM bool) (writeModel *UserV2WriteModel, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + writeModel = NewUserMachineWriteModel(userID, resourceOwner, metadataWM) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + if !isUserStateExists(writeModel.UserState) { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound") + } + return writeModel, nil +} diff --git a/internal/command/user_v2_machine_test.go b/internal/command/user_v2_machine_test.go new file mode 100644 index 0000000000..14df4bfae7 --- /dev/null +++ b/internal/command/user_v2_machine_test.go @@ -0,0 +1,260 @@ +package command + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestCommandSide_ChangeUserMachine(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + orgID string + machine *ChangeMachine + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + + userAgg := user.NewAggregate("user1", "org1") + userAddedEvent := user.NewMachineAddedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ) + + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "change machine username, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change machine username, not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowNotFound(nil, "COMMAND-ugjs0upun6", "Errors.User.NotFound")) + }, + }, + }, + { + name: "change machine username, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &userAgg.Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewUsernameChangedEvent(context.Background(), + &userAgg.Aggregate, + "username", + "changed", + true, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("changed"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change machine username, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Username: gu.Ptr("username"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + Sequence: 0, + EventDate: time.Time{}, + ResourceOwner: "org1", + }, + }, + }, + { + name: "change machine description, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Description: gu.Ptr("changed"), + }, + }, + res: res{ + err: func(err error) bool { + return errors.Is(err, zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied")) + }, + }, + }, + { + name: "change machine description, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + expectPush( + user.NewMachineChangedEvent(context.Background(), + &userAgg.Aggregate, + []user.MachineChanges{ + user.ChangeDescription("changed"), + }, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Description: gu.Ptr("changed"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "change machine description, no change", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(userAddedEvent), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + machine: &ChangeMachine{ + Description: gu.Ptr("description"), + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + err := r.ChangeUserMachine(tt.args.ctx, tt.args.machine) + if tt.res.err == nil { + if !assert.NoError(t, err) { + t.FailNow() + } + } else if !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + return + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, tt.args.machine.Details) + } + }) + } +} diff --git a/internal/command/user_v2_model.go b/internal/command/user_v2_model.go index 214a2a5f9d..92346bf3b6 100644 --- a/internal/command/user_v2_model.go +++ b/internal/command/user_v2_model.go @@ -118,6 +118,14 @@ func NewUserHumanWriteModel(userID, resourceOwner string, profileWM, emailWM, ph return newUserV2WriteModel(userID, resourceOwner, opts...) } +func NewUserMachineWriteModel(userID, resourceOwner string, metadataListWM bool) *UserV2WriteModel { + opts := []UserV2WMOption{WithMachine(), WithState()} + if metadataListWM { + opts = append(opts, WithMetadata()) + } + return newUserV2WriteModel(userID, resourceOwner, opts...) +} + func newUserV2WriteModel(userID, resourceOwner string, opts ...UserV2WMOption) *UserV2WriteModel { wm := &UserV2WriteModel{ WriteModel: eventstore.WriteModel{ diff --git a/internal/domain/permission.go b/internal/domain/permission.go index fd300f63b9..bb569955f5 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -24,7 +24,7 @@ func (p *Permissions) appendPermission(ctxID, permission string) { p.Permissions = append(p.Permissions, permission) } -type PermissionCheck func(ctx context.Context, permission, orgID, resourceID string) (err error) +type PermissionCheck func(ctx context.Context, permission, resourceOwnerID, aggregateID string) (err error) const ( PermissionUserWrite = "user.write" diff --git a/internal/eventstore/write_model.go b/internal/eventstore/write_model.go index 277e65ed82..965fb16d0e 100644 --- a/internal/eventstore/write_model.go +++ b/internal/eventstore/write_model.go @@ -1,6 +1,8 @@ package eventstore -import "time" +import ( + "time" +) // WriteModel is the minimum representation of a command side write model. // It implements a basic reducer @@ -27,21 +29,25 @@ func (wm *WriteModel) Reduce() error { return nil } + latestEvent := wm.Events[len(wm.Events)-1] if wm.AggregateID == "" { - wm.AggregateID = wm.Events[0].Aggregate().ID - } - if wm.ResourceOwner == "" { - wm.ResourceOwner = wm.Events[0].Aggregate().ResourceOwner - } - if wm.InstanceID == "" { - wm.InstanceID = wm.Events[0].Aggregate().InstanceID + wm.AggregateID = latestEvent.Aggregate().ID } - wm.ProcessedSequence = wm.Events[len(wm.Events)-1].Sequence() - wm.ChangeDate = wm.Events[len(wm.Events)-1].CreatedAt() + if wm.ResourceOwner == "" { + wm.ResourceOwner = latestEvent.Aggregate().ResourceOwner + } + + if wm.InstanceID == "" { + wm.InstanceID = latestEvent.Aggregate().InstanceID + } + + wm.ProcessedSequence = latestEvent.Sequence() + wm.ChangeDate = latestEvent.CreatedAt() // all events processed and not needed anymore wm.Events = nil wm.Events = []Event{} + return nil } diff --git a/internal/integration/client.go b/internal/integration/client.go index 320809a7e8..3bf794f5f6 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -141,6 +141,7 @@ func (c *Client) pollHealth(ctx context.Context) (err error) { } } +// Deprecated: use CreateUserTypeHuman instead func (i *Instance) CreateHumanUser(ctx context.Context) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ @@ -172,6 +173,7 @@ func (i *Instance) CreateHumanUser(ctx context.Context) *user_v2.AddHumanUserRes return resp } +// Deprecated: user CreateUserTypeHuman instead func (i *Instance) CreateHumanUserNoPhone(ctx context.Context) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ @@ -197,6 +199,7 @@ func (i *Instance) CreateHumanUserNoPhone(ctx context.Context) *user_v2.AddHuman return resp } +// Deprecated: user CreateUserTypeHuman instead func (i *Instance) CreateHumanUserWithTOTP(ctx context.Context, secret string) *user_v2.AddHumanUserResponse { resp, err := i.Client.UserV2.AddHumanUser(ctx, &user_v2.AddHumanUserRequest{ Organization: &object.Organization{ @@ -229,6 +232,43 @@ func (i *Instance) CreateHumanUserWithTOTP(ctx context.Context, secret string) * return resp } +func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserResponse { + resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ + OrganizationId: i.DefaultOrg.GetId(), + UserType: &user_v2.CreateUserRequest_Human_{ + Human: &user_v2.CreateUserRequest_Human{ + Profile: &user_v2.SetHumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + }, + Email: &user_v2.SetHumanEmail{ + Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Verification: &user_v2.SetHumanEmail_ReturnCode{ + ReturnCode: &user_v2.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }) + logging.OnError(err).Panic("create human user") + i.TriggerUserByID(ctx, resp.GetId()) + return resp +} + +func (i *Instance) CreateUserTypeMachine(ctx context.Context) *user_v2.CreateUserResponse { + resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ + OrganizationId: i.DefaultOrg.GetId(), + UserType: &user_v2.CreateUserRequest_Machine_{ + Machine: &user_v2.CreateUserRequest_Machine{ + Name: "machine", + }, + }, + }) + logging.OnError(err).Panic("create machine user") + i.TriggerUserByID(ctx, resp.GetId()) + return resp +} + // TriggerUserByID makes sure the user projection gets triggered after creation. func (i *Instance) TriggerUserByID(ctx context.Context, users ...string) { var wg sync.WaitGroup diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index 8075422e63..ffbe38e7ae 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -5,6 +5,7 @@ import ( "database/sql" _ "embed" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -18,6 +19,14 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +func keysCheckPermission(ctx context.Context, keys *AuthNKeys, permissionCheck domain.PermissionCheck) { + keys.AuthNKeys = slices.DeleteFunc(keys.AuthNKeys, + func(key *AuthNKey) bool { + return userCheckPermission(ctx, key.ResourceOwner, key.AggregateID, permissionCheck) != nil + }, + ) +} + var ( authNKeyTable = table{ name: projection.AuthNKeyTable, @@ -84,6 +93,7 @@ type AuthNKeys struct { type AuthNKey struct { ID string + AggregateID string CreationDate time.Time ChangeDate time.Time ResourceOwner string @@ -124,12 +134,47 @@ func (q *AuthNKeySearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder return query } -func (q *Queries) SearchAuthNKeys(ctx context.Context, queries *AuthNKeySearchQueries, withOwnerRemoved bool) (authNKeys *AuthNKeys, err error) { +type JoinFilter int + +const ( + JoinFilterUnspecified JoinFilter = iota + JoinFilterApp + JoinFilterUserMachine +) + +// SearchAuthNKeys returns machine or app keys, depending on the join filter. +// If permissionCheck is nil, the keys are not filtered. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is false, the returned keys are filtered in-memory by the given permission check. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is true, the returned keys are filtered in the database. +func (q *Queries) SearchAuthNKeys(ctx context.Context, queries *AuthNKeySearchQueries, filter JoinFilter, permissionCheck domain.PermissionCheck) (authNKeys *AuthNKeys, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + keys, err := q.searchAuthNKeys(ctx, queries, filter, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + keysCheckPermission(ctx, keys, permissionCheck) + } + return keys, nil +} + +func (q *Queries) searchAuthNKeys(ctx context.Context, queries *AuthNKeySearchQueries, joinFilter JoinFilter, permissionCheckV2 bool) (authNKeys *AuthNKeys, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareAuthNKeysQuery() query = queries.toQuery(query) + switch joinFilter { + case JoinFilterUnspecified: + // Select all authN keys + case JoinFilterApp: + joinCol := ProjectColumnID + query = query.Join(joinCol.table.identifier() + " ON " + AuthNKeyColumnIdentifier.identifier() + " = " + joinCol.identifier()) + case JoinFilterUserMachine: + joinCol := MachineUserIDCol + query = query.Join(joinCol.table.identifier() + " ON " + AuthNKeyColumnIdentifier.identifier() + " = " + joinCol.identifier()) + query = userPermissionCheckV2WithCustomColumns(ctx, query, permissionCheckV2, queries.Queries, AuthNKeyColumnResourceOwner, AuthNKeyColumnIdentifier) + } eq := sq.Eq{ AuthNKeyColumnEnabled.identifier(): true, AuthNKeyColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), @@ -249,6 +294,22 @@ func NewAuthNKeyObjectIDQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnObjectID, id, TextEquals) } +func NewAuthNKeyIDQuery(id string) (SearchQuery, error) { + return NewTextQuery(AuthNKeyColumnID, id, TextEquals) +} + +func NewAuthNKeyIdentifyerQuery(id string) (SearchQuery, error) { + return NewTextQuery(AuthNKeyColumnIdentifier, id, TextEquals) +} + +func NewAuthNKeyCreationDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(AuthNKeyColumnCreationDate, ts, compare) +} + +func NewAuthNKeyExpirationDateDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(AuthNKeyColumnExpiration, ts, compare) +} + //go:embed authn_key_user.sql var authNKeyUserQuery string @@ -288,49 +349,52 @@ func (q *Queries) GetAuthNKeyUser(ctx context.Context, keyID, userID string) (_ } func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys, error)) { - return sq.Select( - AuthNKeyColumnID.identifier(), - AuthNKeyColumnCreationDate.identifier(), - AuthNKeyColumnChangeDate.identifier(), - AuthNKeyColumnResourceOwner.identifier(), - AuthNKeyColumnSequence.identifier(), - AuthNKeyColumnExpiration.identifier(), - AuthNKeyColumnType.identifier(), - countColumn.identifier(), - ).From(authNKeyTable.identifier()). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*AuthNKeys, error) { - authNKeys := make([]*AuthNKey, 0) - var count uint64 - for rows.Next() { - authNKey := new(AuthNKey) - err := rows.Scan( - &authNKey.ID, - &authNKey.CreationDate, - &authNKey.ChangeDate, - &authNKey.ResourceOwner, - &authNKey.Sequence, - &authNKey.Expiration, - &authNKey.Type, - &count, - ) - if err != nil { - return nil, err - } - authNKeys = append(authNKeys, authNKey) - } + query := sq.Select( + AuthNKeyColumnID.identifier(), + AuthNKeyColumnAggregateID.identifier(), + AuthNKeyColumnCreationDate.identifier(), + AuthNKeyColumnChangeDate.identifier(), + AuthNKeyColumnResourceOwner.identifier(), + AuthNKeyColumnSequence.identifier(), + AuthNKeyColumnExpiration.identifier(), + AuthNKeyColumnType.identifier(), + countColumn.identifier(), + ).From(authNKeyTable.identifier()). + PlaceholderFormat(sq.Dollar) - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dgfn3", "Errors.Query.CloseRows") + return query, func(rows *sql.Rows) (*AuthNKeys, error) { + authNKeys := make([]*AuthNKey, 0) + var count uint64 + for rows.Next() { + authNKey := new(AuthNKey) + err := rows.Scan( + &authNKey.ID, + &authNKey.AggregateID, + &authNKey.CreationDate, + &authNKey.ChangeDate, + &authNKey.ResourceOwner, + &authNKey.Sequence, + &authNKey.Expiration, + &authNKey.Type, + &count, + ) + if err != nil { + return nil, err } - - return &AuthNKeys{ - AuthNKeys: authNKeys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil + authNKeys = append(authNKeys, authNKey) } + + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-Dgfn3", "Errors.Query.CloseRows") + } + + return &AuthNKeys{ + AuthNKeys: authNKeys, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } } func prepareAuthNKeyQuery() (sq.SelectBuilder, func(row *sql.Row) (*AuthNKey, error)) { diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index c7441f8dae..b7c66cc665 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -19,6 +19,7 @@ import ( var ( prepareAuthNKeysStmt = `SELECT projections.authn_keys2.id,` + + ` projections.authn_keys2.aggregate_id,` + ` projections.authn_keys2.creation_date,` + ` projections.authn_keys2.change_date,` + ` projections.authn_keys2.resource_owner,` + @@ -29,6 +30,7 @@ var ( ` FROM projections.authn_keys2` prepareAuthNKeysCols = []string{ "id", + "aggregate_id", "creation_date", "change_date", "resource_owner", @@ -120,6 +122,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { [][]driver.Value{ { "id", + "aggId", testNow, testNow, "ro", @@ -137,6 +140,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { AuthNKeys: []*AuthNKey{ { ID: "id", + AggregateID: "aggId", CreationDate: testNow, ChangeDate: testNow, ResourceOwner: "ro", @@ -157,6 +161,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { [][]driver.Value{ { "id-1", + "aggId-1", testNow, testNow, "ro", @@ -166,6 +171,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { }, { "id-2", + "aggId-2", testNow, testNow, "ro", @@ -183,6 +189,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { AuthNKeys: []*AuthNKey{ { ID: "id-1", + AggregateID: "aggId-1", CreationDate: testNow, ChangeDate: testNow, ResourceOwner: "ro", @@ -192,6 +199,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { }, { ID: "id-2", + AggregateID: "aggId-2", CreationDate: testNow, ChangeDate: testNow, ResourceOwner: "ro", diff --git a/internal/query/projection/authn_key.go b/internal/query/projection/authn_key.go index e2229ad332..a287701cfb 100644 --- a/internal/query/projection/authn_key.go +++ b/internal/query/projection/authn_key.go @@ -62,6 +62,9 @@ func (*authNKeyProjection) Init() *old_handler.Check { handler.NewPrimaryKey(AuthNKeyInstanceIDCol, AuthNKeyIDCol), handler.WithIndex(handler.NewIndex("enabled", []string{AuthNKeyEnabledCol})), handler.WithIndex(handler.NewIndex("identifier", []string{AuthNKeyIdentifierCol})), + handler.WithIndex(handler.NewIndex("resource_owner", []string{AuthNKeyResourceOwnerCol})), + handler.WithIndex(handler.NewIndex("creation_date", []string{AuthNKeyCreationDateCol})), + handler.WithIndex(handler.NewIndex("expiration_date", []string{AuthNKeyExpirationCol})), ), ) } diff --git a/internal/query/projection/user_personal_access_token.go b/internal/query/projection/user_personal_access_token.go index 0efb5d6412..610ca9c4e2 100644 --- a/internal/query/projection/user_personal_access_token.go +++ b/internal/query/projection/user_personal_access_token.go @@ -56,6 +56,8 @@ func (*personalAccessTokenProjection) Init() *old_handler.Check { handler.WithIndex(handler.NewIndex("user_id", []string{PersonalAccessTokenColumnUserID})), handler.WithIndex(handler.NewIndex("resource_owner", []string{PersonalAccessTokenColumnResourceOwner})), handler.WithIndex(handler.NewIndex("owner_removed", []string{PersonalAccessTokenColumnOwnerRemoved})), + handler.WithIndex(handler.NewIndex("creation_date", []string{PersonalAccessTokenColumnCreationDate})), + handler.WithIndex(handler.NewIndex("expiration_date", []string{PersonalAccessTokenColumnExpiration})), ), ) } diff --git a/internal/query/user.go b/internal/query/user.go index 3d47847cac..6844982f07 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -132,16 +132,20 @@ func usersCheckPermission(ctx context.Context, users *Users, permissionCheck dom ) } -func userPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *UserSearchQueries) sq.SelectBuilder { +func userPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, filters []SearchQuery) sq.SelectBuilder { + return userPermissionCheckV2WithCustomColumns(ctx, query, enabled, filters, UserResourceOwnerCol, UserIDCol) +} + +func userPermissionCheckV2WithCustomColumns(ctx context.Context, query sq.SelectBuilder, enabled bool, filters []SearchQuery, userResourceOwnerCol, userID Column) sq.SelectBuilder { if !enabled { return query } join, args := PermissionClause( ctx, - UserResourceOwnerCol, + userResourceOwnerCol, domain.PermissionUserRead, - SingleOrgPermissionOption(queries.Queries), - OwnedRowsPermissionOption(UserIDCol), + SingleOrgPermissionOption(filters), + OwnedRowsPermissionOption(userID), ) return query.JoinClause(join, args...) } @@ -637,7 +641,7 @@ func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, p defer func() { span.EndWithError(err) }() query, scan := prepareUsersQuery() - query = userPermissionCheckV2(ctx, query, permissionCheckV2, queries) + query = userPermissionCheckV2(ctx, query, permissionCheckV2, queries.Queries) stmt, args, err := queries.toQuery(query).Where(sq.Eq{ UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), }).ToSql() diff --git a/internal/query/user_personal_access_token.go b/internal/query/user_personal_access_token.go index 8ea33f51a4..61d349961c 100644 --- a/internal/query/user_personal_access_token.go +++ b/internal/query/user_personal_access_token.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -11,12 +12,21 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) +func patsCheckPermission(ctx context.Context, tokens *PersonalAccessTokens, permissionCheck domain.PermissionCheck) { + tokens.PersonalAccessTokens = slices.DeleteFunc(tokens.PersonalAccessTokens, + func(token *PersonalAccessToken) bool { + return userCheckPermission(ctx, token.ResourceOwner, token.UserID, permissionCheck) != nil + }, + ) +} + var ( personalAccessTokensTable = table{ name: projection.PersonalAccessTokenProjectionTable, @@ -86,7 +96,7 @@ type PersonalAccessTokenSearchQueries struct { Queries []SearchQuery } -func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk bool, id string, withOwnerRemoved bool, queries ...SearchQuery) (pat *PersonalAccessToken, err error) { +func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk bool, id string, queries ...SearchQuery) (pat *PersonalAccessToken, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -102,11 +112,9 @@ func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk query = q.toQuery(query) } eq := sq.Eq{ - PersonalAccessTokenColumnID.identifier(): id, - PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - } - if !withOwnerRemoved { - eq[PersonalAccessTokenColumnOwnerRemoved.identifier()] = false + PersonalAccessTokenColumnID.identifier(): id, + PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + PersonalAccessTokenColumnOwnerRemoved.identifier(): false, } stmt, args, err := query.Where(eq).ToSql() if err != nil { @@ -123,18 +131,34 @@ func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk return pat, nil } -func (q *Queries) SearchPersonalAccessTokens(ctx context.Context, queries *PersonalAccessTokenSearchQueries, withOwnerRemoved bool) (personalAccessTokens *PersonalAccessTokens, err error) { +// SearchPersonalAccessTokens returns personal access token resources. +// If permissionCheck is nil, the PATs are not filtered. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is false, the returned PATs are filtered in-memory by the given permission check. +// If permissionCheck is not nil and the PermissionCheckV2 feature flag is true, the returned PATs are filtered in the database. +func (q *Queries) SearchPersonalAccessTokens(ctx context.Context, queries *PersonalAccessTokenSearchQueries, permissionCheck domain.PermissionCheck) (authNKeys *PersonalAccessTokens, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + keys, err := q.searchPersonalAccessTokens(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + patsCheckPermission(ctx, keys, permissionCheck) + } + return keys, nil +} + +func (q *Queries) searchPersonalAccessTokens(ctx context.Context, queries *PersonalAccessTokenSearchQueries, permissionCheckV2 bool) (personalAccessTokens *PersonalAccessTokens, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := preparePersonalAccessTokensQuery() + query = queries.toQuery(query) + query = userPermissionCheckV2WithCustomColumns(ctx, query, permissionCheckV2, queries.Queries, PersonalAccessTokenColumnResourceOwner, PersonalAccessTokenColumnUserID) eq := sq.Eq{ - PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + PersonalAccessTokenColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + PersonalAccessTokenColumnOwnerRemoved.identifier(): false, } - if !withOwnerRemoved { - eq[PersonalAccessTokenColumnOwnerRemoved.identifier()] = false - } - stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + stmt, args, err := query.Where(eq).ToSql() if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "QUERY-Hjw2w", "Errors.Query.InvalidRequest") } @@ -160,6 +184,18 @@ func NewPersonalAccessTokenUserIDSearchQuery(value string) (SearchQuery, error) return NewTextQuery(PersonalAccessTokenColumnUserID, value, TextEquals) } +func NewPersonalAccessTokenIDQuery(id string) (SearchQuery, error) { + return NewTextQuery(PersonalAccessTokenColumnID, id, TextEquals) +} + +func NewPersonalAccessTokenCreationDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(PersonalAccessTokenColumnCreationDate, ts, compare) +} + +func NewPersonalAccessTokenExpirationDateDateQuery(ts time.Time, compare TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(PersonalAccessTokenColumnExpiration, ts, compare) +} + func (r *PersonalAccessTokenSearchQueries) AppendMyResourceOwnerQuery(orgID string) error { query, err := NewPersonalAccessTokenResourceOwnerSearchQuery(orgID) if err != nil { diff --git a/internal/repository/user/machine.go b/internal/repository/user/machine.go index d76290931a..a466f92fe3 100644 --- a/internal/repository/user/machine.go +++ b/internal/repository/user/machine.go @@ -88,10 +88,7 @@ func NewMachineChangedEvent( ctx context.Context, aggregate *eventstore.Aggregate, changes []MachineChanges, -) (*MachineChangedEvent, error) { - if len(changes) == 0 { - return nil, zerrors.ThrowPreconditionFailed(nil, "USER-3M9fs", "Errors.NoChangesFound") - } +) *MachineChangedEvent { changeEvent := &MachineChangedEvent{ BaseEvent: *eventstore.NewBaseEventForPush( ctx, @@ -102,7 +99,7 @@ func NewMachineChangedEvent( for _, change := range changes { change(changeEvent) } - return changeEvent, nil + return changeEvent } type MachineChanges func(event *MachineChangedEvent) diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 8254b82b45..d58b2eb64a 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -117,6 +117,7 @@ Errors: AlreadyVerified: Телефонът вече е потвърден Empty: Телефонът е празен NotChanged: Телефонът не е сменен + VerifyingRemovalIsNotSupported: Премахването на проверката не се поддържа Address: NotFound: Адресът не е намерен NotChanged: Адресът не е променен diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index bb4172fbff..d248ce4ca7 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefon již ověřen Empty: Telefon je prázdný NotChanged: Telefon nezměněn + VerifyingRemovalIsNotSupported: Ověření odstranění telefonu není podporováno Address: NotFound: Adresa nenalezena NotChanged: Adresa nezměněna diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index a24ce7c933..96edf57456 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefonnummer bereits verifiziert Empty: Telefonnummer ist leer NotChanged: Telefonnummer wurde nicht geändert + VerifyingRemovalIsNotSupported: Verifizieren der Telefonnummer Entfernung wird nicht unterstützt Address: NotFound: Adresse nicht gefunden NotChanged: Adresse wurde nicht geändert diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index e8f2781de1..0f512defe4 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: Phone already verified Empty: Phone is empty NotChanged: Phone not changed + VerifyingRemovalIsNotSupported: Verifying phone removal is not supported Address: NotFound: Address not found NotChanged: Address not changed diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index b91d055f70..8c901f8ebe 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: El teléfono ya se verificó Empty: El teléfono está vacío NotChanged: El teléfono no ha cambiado + VerifyingRemovalIsNotSupported: La verificación de eliminación no está soportada Address: NotFound: Dirección no encontrada NotChanged: La dirección no ha cambiado diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 98f2bee9a0..2a2a51d7c4 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Téléphone déjà vérifié Empty: Téléphone est vide NotChanged: Téléphone n'a pas changé + VerifyingRemovalIsNotSupported: La vérification de la suppression n'est pas prise en charge Address: NotFound: Adresse non trouvée NotChanged: L'adresse n'a pas changé diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index 5becd6e606..a4cc908fa2 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefon már ellenőrizve Empty: A telefon mező üres NotChanged: Telefon nem lett megváltoztatva + VerifyingRemovalIsNotSupported: A telefon eltávolításának ellenőrzése nem támogatott Address: NotFound: Cím nem található NotChanged: Cím nem lett megváltoztatva diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 0108d7618b..c9187020f7 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telepon sudah diverifikasi Empty: Telepon kosong NotChanged: Telepon tidak berubah + VerifyingRemovalIsNotSupported: Verifikasi penghapusan tidak didukung Address: NotFound: Alamat tidak ditemukan NotChanged: Alamat tidak berubah diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 750c48471a..d1dccef4c7 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefono già verificato Empty: Il telefono è vuoto NotChanged: Telefono non cambiato + VerifyingRemovalIsNotSupported: La rimozione della verifica non è supportata Address: NotFound: Indirizzo non trovato NotChanged: Indirizzo non cambiato diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index fcd7920999..4b0f2ea203 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: 電話番号はすでに認証済みです Empty: 電話番号が空です NotChanged: 電話番号が変更されていません + VerifyingRemovalIsNotSupported: 電話番号の削除を検証することはできません Address: NotFound: 住所が見つかりません NotChanged: 住所は変更されていません diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index d83af62235..2c87aa1f97 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: 전화번호가 이미 인증되었습니다 Empty: 전화번호가 비어 있습니다 NotChanged: 전화번호가 변경되지 않았습니다 + VerifyingRemovalIsNotSupported: 전화번호 제거를 확인하는 것은 지원되지 않습니다 Address: NotFound: 주소를 찾을 수 없습니다 NotChanged: 주소가 변경되지 않았습니다 diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 7126925279..64ae87a618 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Телефонскиот број веќе е верифициран Empty: Телефонскиот број е празен NotChanged: Телефонскиот број не е променет + VerifyingRemovalIsNotSupported: Отстранувањето на верификацијата не е поддржано Address: NotFound: Адресата не е пронајдена NotChanged: Адресата не е променета diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index a398e4b770..dc9fd83721 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Telefoon is al geverifieerd Empty: Telefoon is leeg NotChanged: Telefoon niet veranderd + VerifyingRemovalIsNotSupported: Verwijderen van verificatie is niet ondersteund Address: NotFound: Adres niet gevonden NotChanged: Adres niet veranderd diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 049a189930..4952345510 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Numer telefonu już zweryfikowany Empty: Numer telefonu jest pusty NotChanged: Numer telefonu nie zmieniony + VerifyingRemovalIsNotSupported: Usunięcie weryfikacji nie jest obsługiwane Address: NotFound: Adres nie znaleziony NotChanged: Adres nie zmieniony diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 09a5fc02c5..e5fc785d0c 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: O telefone já foi verificado Empty: O telefone está vazio NotChanged: Telefone não alterado + VerifyingRemovalIsNotSupported: Remoção de verificação não suportada Address: NotFound: Endereço não encontrado NotChanged: Endereço não alterado diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index 9010e57032..ece4680de6 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: Numărul de telefon este deja verificat Empty: Numărul de telefon este gol NotChanged: Numărul de telefon nu a fost schimbat + VerifyingRemovalIsNotSupported: Verificarea eliminării nu este acceptată Address: NotFound: Adresa nu a fost găsită NotChanged: Adresa nu a fost schimbată diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 38b2847637..a2efd25322 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -116,6 +116,7 @@ Errors: AlreadyVerified: Телефон уже подтверждён Empty: Телефон пуст NotChanged: Телефон не менялся + VerifyingRemovalIsNotSupported: Удаление телефона не поддерживается Address: NotFound: Адрес не найден NotChanged: Адрес не изменён diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index ed4b863886..be40ceba3c 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: Mobilnr redan verifierad Empty: Mobilnr är tom NotChanged: Mobilnr ändrades inte + VerifyingRemovalIsNotSupported: Verifiering av borttagning stöds inte Address: NotFound: Adress hittades inte NotChanged: Adress ändrades inte diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 03aa168a50..930fcaddae 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -115,6 +115,7 @@ Errors: AlreadyVerified: 手机号码已经验证 Empty: 电话号码是空的 NotChanged: 电话号码没有改变 + VerifyingRemovalIsNotSupported: 验证手机号码删除不受支持 Address: NotFound: 找不到地址 NotChanged: 地址没有改变 diff --git a/proto/buf.yaml b/proto/buf.yaml index 31bc7b4ccc..abe35b3055 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -40,4 +40,4 @@ lint: - zitadel/system.proto - zitadel/text.proto - zitadel/user.proto - - zitadel/v1.proto \ No newline at end of file + - zitadel/v1.proto diff --git a/proto/zitadel/filter/v2/filter.proto b/proto/zitadel/filter/v2/filter.proto new file mode 100644 index 0000000000..3817324d31 --- /dev/null +++ b/proto/zitadel/filter/v2/filter.proto @@ -0,0 +1,96 @@ +syntax = "proto3"; + +package zitadel.filter.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/filter/v2;filter"; + +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +enum TextFilterMethod { + TEXT_FILTER_METHOD_EQUALS = 0; + TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE = 1; + TEXT_FILTER_METHOD_STARTS_WITH = 2; + TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE = 3; + TEXT_FILTER_METHOD_CONTAINS = 4; + TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE = 5; + TEXT_FILTER_METHOD_ENDS_WITH = 6; + TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE = 7; +} + +enum ListFilterMethod { + LIST_FILTER_METHOD_IN = 0; +} + +enum TimestampFilterMethod { + TIMESTAMP_FILTER_METHOD_EQUALS = 0; + TIMESTAMP_FILTER_METHOD_AFTER = 1; + TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS = 2; + TIMESTAMP_FILTER_METHOD_BEFORE = 3; + TIMESTAMP_FILTER_METHOD_BEFORE_OR_EQUALS = 4; +} + +message PaginationRequest { + // Starting point for retrieval, in combination of offset used to query a set list of objects. + uint64 offset = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "0"; + } + ]; + // limit is the maximum amount of objects returned. The default is set to 100 + // with a maximum of 1000 in the runtime configuration. + // If the limit exceeds the maximum configured ZITADEL will throw an error. + // If no limit is present the default is taken. + uint32 limit = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "10"; + } + ]; + // Asc is the sorting order. If true the list is sorted ascending, if false + // the list is sorted descending. The default is descending. + bool asc = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "false"; + } + ]; +} + +message PaginationResponse { + // Absolute number of objects matching the query, regardless of applied limit. + uint64 total_result = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "100"; + } + ]; + // Applied limit from query, defines maximum amount of objects per request, to compare if all objects are returned. + uint64 applied_limit = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "100"; + } + ]; +} + +message IDFilter { + // Only return resources that belong to this id. + string id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"69629023906488337\""; + } + ]; +} + +message TimestampFilter { + // Filter resources by timestamp. + google.protobuf.Timestamp timestamp = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // Defines the condition (e.g., equals, before, after) that the timestamp of the retrieved resources should match. + TimestampFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} diff --git a/proto/zitadel/filter/v2beta/filter.proto b/proto/zitadel/filter/v2beta/filter.proto index 6aae583cde..2265fa4125 100644 --- a/proto/zitadel/filter/v2beta/filter.proto +++ b/proto/zitadel/filter/v2beta/filter.proto @@ -6,6 +6,7 @@ option go_package = "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta;filter"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; enum TextFilterMethod { TEXT_FILTER_METHOD_EQUALS = 0; @@ -56,4 +57,37 @@ message PaginationResponse { example: "\"100\""; } ]; -} \ No newline at end of file +} + +message IDFilter { + // Only return resources that belong to this id. + string id = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"69629023906488337\""; + } + ]; +} + +message TimestampFilter { + // Filter resources by timestamp. + google.protobuf.Timestamp timestamp = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // Defines the condition (e.g., equals, before, after) that the timestamp of the retrieved resources should match. + TimestampFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true + ]; +} + +message InIDsFilter { + // Defines the ids to query for. + repeated string ids = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 34a8384d39..8cd0b22759 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -432,7 +432,11 @@ service ManagementService { }; } - // Deprecated: use ImportHumanUser + // Create User (Human) + // + // Deprecated: use [ImportHumanUser](apis/resources/mgmt/management-service-import-human-user.api.mdx) instead. + // + // Create a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login. rpc AddHumanUser(AddHumanUserRequest) returns (AddHumanUserResponse) { option (google.api.http) = { post: "/users/human" @@ -444,10 +448,8 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Deprecated: Create User (Human)"; - description: "Create a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login.\n\nDeprecated: use ImportHumanUser" - tags: "Users"; deprecated: true; + tags: "Users"; parameters: { headers: { name: "x-zitadel-orgid"; @@ -459,7 +461,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 AddHumanUser + // Create/Import User (Human) + // + // Deprecated: use [UpdateHumanUser](apis/resources/user_service_v2/user-service-update-human-user.api.mdx) instead. + // + // Create/import a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login. rpc ImportHumanUser(ImportHumanUserRequest) returns (ImportHumanUserResponse) { option (google.api.http) = { post: "/users/human/_import" @@ -471,11 +477,9 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create/Import User (Human)"; - description: "Create/import a new user with the type human. The newly created user will get an initialization email if either the email address is not marked as verified or no password is set. If a password is set the user will not be requested to set a new one on the first login.\n\nDeprecated: please use user service v2 [AddHumanUser](apis/resources/user_service_v2/user-service-add-human-user.api.mdx)" + deprecated: true; tags: "Users"; tags: "User Human" - deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -487,6 +491,11 @@ service ManagementService { }; } + // Create User (Machine) + // + // Deprecated: use [user service v2 CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a user of type machine instead. + // + // Create a new user with the type machine for your API, service or device. These users are used for non-interactive authentication flows. rpc AddMachineUser(AddMachineUserRequest) returns (AddMachineUserResponse) { option (google.api.http) = { post: "/users/machine" @@ -498,8 +507,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create User (Machine)"; - description: "Create a new user with the type machine for your API, service or device. These users are used for non-interactive authentication flows." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -683,7 +691,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 UpdateHumanUser + // Change user name + // + // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. + // + // Change the username of the user. Be aware that the user has to log in with the newly added username afterward rpc UpdateUserName(UpdateUserNameRequest) returns (UpdateUserNameResponse) { option (google.api.http) = { put: "/users/{user_id}/username" @@ -695,8 +707,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Change user name"; - description: "Change the username of the user. Be aware that the user has to log in with the newly added username afterward.\n\nDeprecated: please use user service v2 UpdateHumanUser" tags: "Users"; deprecated: true; responses: { @@ -903,7 +913,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 UpdateHumanUser + // Update User Profile (Human) + // + // Deprecated: use [user service v2 UpdateHumanUser](apis/resources/user_service_v2/user-service-update-human-user.api.mdx) instead. + // + // Update the profile information from a user. The profile includes basic information like first_name and last_name. rpc UpdateHumanProfile(UpdateHumanProfileRequest) returns (UpdateHumanProfileResponse) { option (google.api.http) = { put: "/users/{user_id}/profile" @@ -915,11 +929,9 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update User Profile (Human)"; - description: "Update the profile information from a user. The profile includes basic information like first_name and last_name.\n\nDeprecated: please use user service v2 UpdateHumanUser" + deprecated: true; tags: "Users"; tags: "User Human"; - deprecated: true; responses: { key: "200" value: { @@ -970,7 +982,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetEmail + // Update User Email (Human) + // + // Deprecated: use [user service v2 SetEmail](apis/resources/user_service_v2/user-service-set-email.api.mdx) instead. + // + // Change the email address of a user. If the state is set to not verified, the user will get a verification email. rpc UpdateHumanEmail(UpdateHumanEmailRequest) returns (UpdateHumanEmailResponse) { option (google.api.http) = { put: "/users/{user_id}/email" @@ -982,8 +998,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update User Email (Human)"; - description: "Change the email address of a user. If the state is set to not verified, the user will get a verification email.\n\nDeprecated: please use user service v2 SetEmail" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1039,7 +1053,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 ResendEmailCode + // Resend User Email Verification + // + // Deprecated: use [user service v2 ResendEmailCode](apis/resources/user_service_v2/user-service-resend-email-code.api.mdx) instead. + // + // Resend the email verification notification to the given email address of the user. rpc ResendHumanEmailVerification(ResendHumanEmailVerificationRequest) returns (ResendHumanEmailVerificationResponse) { option (google.api.http) = { post: "/users/{user_id}/email/_resend_verification" @@ -1051,8 +1069,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Resend User Email Verification"; - description: "Resend the email verification notification to the given email address of the user.\n\nDeprecated: please use user service v2 ResendEmailCode" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1106,7 +1122,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPhone + // Update User Phone (Human) + // + // Deprecated: use [user service v2 SetPhone](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. + // + // Change the phone number of a user. If the state is set to not verified, the user will get an SMS to verify (if a notification provider is configured). The phone number is only for informational purposes and to send messages, not for Authentication (2FA). rpc UpdateHumanPhone(UpdateHumanPhoneRequest) returns (UpdateHumanPhoneResponse) { option (google.api.http) = { put: "/users/{user_id}/phone" @@ -1118,8 +1138,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update User Phone (Human)"; - description: "Change the phone number of a user. If the state is set to not verified, the user will get an SMS to verify (if a notification provider is configured). The phone number is only for informational purposes and to send messages, not for Authentication (2FA).\n\nDeprecated: please use user service v2 SetPhone" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1140,7 +1158,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPhone + // Remove User Phone (Human) + // + // Deprecated: use user service v2 [user service v2 SetPhone](apis/resources/user_service_v2/user-service-set-phone.api.mdx) instead. + // + // Remove the configured phone number of a user. rpc RemoveHumanPhone(RemoveHumanPhoneRequest) returns (RemoveHumanPhoneResponse) { option (google.api.http) = { delete: "/users/{user_id}/phone" @@ -1151,8 +1173,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Remove User Phone (Human)"; - description: "Remove the configured phone number of a user.\n\nDeprecated: please use user service v2 SetPhone" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1173,7 +1193,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 ResendPhoneCode + // Resend User Phone Verification + // + // Deprecated: use user service v2 [user service v2 ResendPhoneCode](apis/resources/user_service_v2/user-service-resend-phone-code.api.mdx) instead. + // + // Resend the notification for the verification of the phone number, to the number stored on the user. rpc ResendHumanPhoneVerification(ResendHumanPhoneVerificationRequest) returns (ResendHumanPhoneVerificationResponse) { option (google.api.http) = { post: "/users/{user_id}/phone/_resend_verification" @@ -1185,8 +1209,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Resend User Phone Verification"; - description: "Resend the notification for the verification of the phone number, to the number stored on the user.\n\nDeprecated: please use user service v2 ResendPhoneCode" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1238,7 +1260,9 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPassword + // Set Human Initial Password + // + // Deprecated: use [user service v2 SetPassword](apis/resources/user_service_v2/user-service-set-password.api.mdx) instead. rpc SetHumanInitialPassword(SetHumanInitialPasswordRequest) returns (SetHumanInitialPasswordResponse) { option (google.api.http) = { post: "/users/{user_id}/password/_initialize" @@ -1252,7 +1276,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; tags: "User Human"; - summary: "Set Human Initial Password\n\nDeprecated: please use user service v2 SetPassword"; deprecated: true; parameters: { headers: { @@ -1265,7 +1288,9 @@ service ManagementService { }; } - // Deprecated: please use user service v2 SetPassword + // Set User Password + // + // Deprecated: use [user service v2 SetPassword](apis/resources/user_service_v2/user-service-set-password.api.mdx) instead. rpc SetHumanPassword(SetHumanPasswordRequest) returns (SetHumanPasswordResponse) { option (google.api.http) = { post: "/users/{user_id}/password" @@ -1277,8 +1302,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Set User Password"; - description: "Set a new password for a user. Per default, the user has to change the password on the next login. You can set no_change_required to true, to avoid the change on the next login.\n\nDeprecated: please use user service v2 SetPassword" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1299,7 +1322,11 @@ service ManagementService { }; } - // Deprecated: please use user service v2 PasswordReset + // Send Reset Password Notification + // + // Deprecated: use [user service v2 PasswordReset](apis/resources/user_service_v2/user-service-password-reset.api.mdx) instead. + // + // The user will receive an email with a link to change the password. rpc SendHumanResetPasswordNotification(SendHumanResetPasswordNotificationRequest) returns (SendHumanResetPasswordNotificationResponse) { option (google.api.http) = { post: "/users/{user_id}/password/_reset" @@ -1311,8 +1338,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Send Reset Password Notification"; - description: "The user will receive an email with a link to change the password.\n\nDeprecated: please use user service v2 PasswordReset" tags: "Users"; tags: "User Human"; deprecated: true; @@ -1629,6 +1654,11 @@ service ManagementService { }; } + // Update Machine User + // + // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type machine instead. + // + // Change a service account/machine user. It is used for accounts with non-interactive authentication possibilities. rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse) { option (google.api.http) = { put: "/users/{user_id}/machine" @@ -1640,8 +1670,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Update Machine User"; - description: "Change a service account/machine user. It is used for accounts with non-interactive authentication possibilities." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1661,6 +1690,11 @@ service ManagementService { }; } + // Create Secret for Machine User + // + // Deprecated: use [user service v2 AddSecret](apis/resources/user_service_v2/user-service-add-secret.api.mdx) instead. + // + // Create a new secret for a machine user/service account. It is used to authenticate the user (client credential grant). rpc GenerateMachineSecret(GenerateMachineSecretRequest) returns (GenerateMachineSecretResponse) { option (google.api.http) = { put: "/users/{user_id}/secret" @@ -1672,8 +1706,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create Secret for Machine User"; - description: "Create a new secret for a machine user/service account. It is used to authenticate the user (client credential grant)." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1693,6 +1726,11 @@ service ManagementService { }; } + // Delete Secret of Machine User + // + // Deprecated: use [user service v2 RemoveSecret](apis/resources/user_service_v2/user-service-remove-secret.api.mdx) instead. + // + // Delete a secret of a machine user/service account. The user will not be able to authenticate with the secret afterward. rpc RemoveMachineSecret(RemoveMachineSecretRequest) returns (RemoveMachineSecretResponse) { option (google.api.http) = { delete: "/users/{user_id}/secret" @@ -1703,8 +1741,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Delete Secret of Machine User"; - description: "Delete a secret of a machine user/service account. The user will not be able to authenticate with the secret afterward." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1724,6 +1761,11 @@ service ManagementService { }; } + // Get Machine user Key By ID + // + // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. + // + // Get a specific Key of a machine user by its id. Machine keys are used to authenticate with jwt profile authentication. rpc GetMachineKeyByIDs(GetMachineKeyByIDsRequest) returns (GetMachineKeyByIDsResponse) { option (google.api.http) = { get: "/users/{user_id}/keys/{key_id}" @@ -1734,8 +1776,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get Machine user Key By ID"; - description: "Get a specific Key of a machine user by its id. Machine keys are used to authenticate with jwt profile authentication." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1755,6 +1796,11 @@ service ManagementService { }; } + // Get Machine user Key By ID + // + // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. + // + // Get the list of keys of a machine user. Machine keys are used to authenticate with jwt profile authentication. rpc ListMachineKeys(ListMachineKeysRequest) returns (ListMachineKeysResponse) { option (google.api.http) = { post: "/users/{user_id}/keys/_search" @@ -1766,8 +1812,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get Machine user Key By ID"; - description: "Get the list of keys of a machine user. Machine keys are used to authenticate with jwt profile authentication." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1787,6 +1832,14 @@ service ManagementService { }; } + // Create Key for machine user + // + // Deprecated: use [user service v2 AddKey](apis/resources/user_service_v2/user-service-add-key.api.mdx) instead. + // + // If a public key is not supplied, a new key is generated and will be returned in the response. + // Make sure to store the returned key. + // If an RSA public key is supplied, the private key is omitted from the response. + // Machine keys are used to authenticate with jwt profile. rpc AddMachineKey(AddMachineKeyRequest) returns (AddMachineKeyResponse) { option (google.api.http) = { post: "/users/{user_id}/keys" @@ -1798,8 +1851,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create Key for machine user"; - description: "If a public key is not supplied, a new key is generated and will be returned in the response. Make sure to store the returned key. If an RSA public key is supplied, the private key is omitted from the response. Machine keys are used to authenticate with jwt profile." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1819,6 +1871,12 @@ service ManagementService { }; } + // Delete Key for machine user + // + // Deprecated: use [user service v2 RemoveKey](apis/resources/user_service_v2/user-service-remove-key.api.mdx) instead. + // + // Delete a specific key from a user. + // The user will not be able to authenticate with that key afterward. rpc RemoveMachineKey(RemoveMachineKeyRequest) returns (RemoveMachineKeyResponse) { option (google.api.http) = { delete: "/users/{user_id}/keys/{key_id}" @@ -1829,8 +1887,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Delete Key for machine user"; - description: "Delete a specific key from a user. The user will not be able to authenticate with that key afterward." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1850,6 +1907,11 @@ service ManagementService { }; } + // Get Personal-Access-Token (PAT) by ID + // + // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. + // + // Returns the PAT for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc GetPersonalAccessTokenByIDs(GetPersonalAccessTokenByIDsRequest) returns (GetPersonalAccessTokenByIDsResponse) { option (google.api.http) = { get: "/users/{user_id}/pats/{token_id}" @@ -1860,8 +1922,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get a Personal-Access-Token (PAT) by ID"; - description: "Returns the PAT for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1881,6 +1942,11 @@ service ManagementService { }; } + // List Personal-Access-Tokens (PATs) + // + // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. + // + // Returns a list of PATs for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { option (google.api.http) = { post: "/users/{user_id}/pats/_search" @@ -1892,8 +1958,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get a Personal-Access-Token (PAT) by ID"; - description: "Returns a list of PATs for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1913,6 +1978,13 @@ service ManagementService { }; } + // Create a Personal-Access-Token (PAT) + // + // Deprecated: use [user service v2 AddPersonalAccessToken](apis/resources/user_service_v2/user-service-add-personal-access-token.api.mdx) instead. + // + // Generates a new PAT for the user. Currently only available for machine users. + // The token will be returned in the response, make sure to store it. + // PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { option (google.api.http) = { post: "/users/{user_id}/pats" @@ -1924,8 +1996,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Create a Personal-Access-Token (PAT)"; - description: "Generates a new PAT for the user. Currently only available for machine users. The token will be returned in the response, make sure to store it. PATs are ready-to-use tokens and can be sent directly in the authentication header." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1945,6 +2016,11 @@ service ManagementService { }; } + // Remove a Personal-Access-Token (PAT) by ID + // + // Deprecated: use [user service v2 RemovePersonalAccessToken](apis/resources/user_service_v2/user-service-remove-personal-access-token.api.mdx) instead. + // + // Delete a PAT from a user. Afterward, the user will not be able to authenticate with that token anymore. rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { option (google.api.http) = { delete: "/users/{user_id}/pats/{token_id}" @@ -1955,8 +2031,7 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Get a Personal-Access-Token (PAT) by ID"; - description: "Delete a PAT from a user. Afterward, the user will not be able to authenticate with that token anymore." + deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -2003,7 +2078,7 @@ service ManagementService { }; } - // Deprecated: please use user service v2 RemoveLinkedIDP + // Deprecated: please use [user service v2 RemoveIDPLink](apis/resources/user_service_v2/user-service-remove-idp-link.api.mdx) rpc RemoveHumanLinkedIDP(RemoveHumanLinkedIDPRequest) returns (RemoveHumanLinkedIDPResponse) { option (google.api.http) = { delete: "/users/{user_id}/idps/{idp_id}/{linked_user_id}" diff --git a/proto/zitadel/project/v2beta/query.proto b/proto/zitadel/project/v2beta/query.proto index f328b65189..9bfde662a3 100644 --- a/proto/zitadel/project/v2beta/query.proto +++ b/proto/zitadel/project/v2beta/query.proto @@ -185,10 +185,10 @@ message ProjectSearchFilter { option (validate.required) = true; ProjectNameFilter project_name_filter = 1; - InProjectIDsFilter in_project_ids_filter = 2; - ProjectResourceOwnerFilter project_resource_owner_filter = 3; - ProjectGrantResourceOwnerFilter project_grant_resource_owner_filter = 4; - ProjectOrganizationIDFilter project_organization_id_filter = 5; + zitadel.filter.v2beta.InIDsFilter in_project_ids_filter = 2; + zitadel.filter.v2beta.IDFilter project_resource_owner_filter = 3; + zitadel.filter.v2beta.IDFilter project_grant_resource_owner_filter = 4; + zitadel.filter.v2beta.IDFilter project_organization_id_filter = 5; } } @@ -210,68 +210,18 @@ message ProjectNameFilter { ]; } -message InProjectIDsFilter { - // Defines the ids to query for. - repeated string project_ids = 1 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "the ids of the projects to include" - example: "[\"69629023906488334\",\"69622366012355662\"]"; - } - ]; -} - -message ProjectResourceOwnerFilter { - // Defines the ID of organization the project belongs to query for. - string project_resource_owner = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - -message ProjectGrantResourceOwnerFilter { - // Defines the ID of organization the project grant belongs to query for. - string project_grant_resource_owner = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - -message ProjectOrganizationIDFilter { - // Defines the ID of organization the project and granted project belong to query for. - string project_organization_id = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - message ProjectGrantSearchFilter { oneof filter { option (validate.required) = true; ProjectNameFilter project_name_filter = 1; ProjectRoleKeyFilter role_key_filter = 2; - InProjectIDsFilter in_project_ids_filter = 3; - ProjectResourceOwnerFilter project_resource_owner_filter = 4; - ProjectGrantResourceOwnerFilter project_grant_resource_owner_filter = 5; + zitadel.filter.v2beta.InIDsFilter in_project_ids_filter = 3; + zitadel.filter.v2beta.IDFilter project_resource_owner_filter = 4; + zitadel.filter.v2beta.IDFilter project_grant_resource_owner_filter = 5; } } -message GrantedOrganizationIDFilter { - // Defines the ID of organization the project is granted to query for. - string granted_organization_id = 1 [ - (validate.rules).string = {max_len: 200}, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "\"69629023906488334\"" - } - ]; -} - message ProjectRole { // ID of the project. string project_id = 1 [ @@ -344,4 +294,4 @@ message ProjectRoleDisplayNameFilter { zitadel.filter.v2beta.TextFilterMethod method = 2 [ (validate.rules).enum.defined_only = true ]; -} \ No newline at end of file +} diff --git a/proto/zitadel/user/v2/email.proto b/proto/zitadel/user/v2/email.proto index e962707fcf..eb807206a7 100644 --- a/proto/zitadel/user/v2/email.proto +++ b/proto/zitadel/user/v2/email.proto @@ -19,7 +19,7 @@ message SetHumanEmail { example: "\"mini@mouse.com\""; } ]; - // if no verification is specified, an email is sent with the default url + // If no verification is specified, an email is sent with the default url oneof verification { SendEmailVerificationCode send_code = 2; ReturnEmailVerificationCode return_code = 3; diff --git a/proto/zitadel/user/v2/key.proto b/proto/zitadel/user/v2/key.proto new file mode 100644 index 0000000000..ffa83c714e --- /dev/null +++ b/proto/zitadel/user/v2/key.proto @@ -0,0 +1,69 @@ +syntax = "proto3"; + +package zitadel.user.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "zitadel/filter/v2/filter.proto"; + +message Key { + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change of the key. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The unique identifier of the key. + string id = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the user the key belongs to. + string user_id = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the organization the key belongs to. + string organization_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The keys expiration date. + google.protobuf.Timestamp expiration_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message KeysSearchFilter { + oneof filter { + option (validate.required) = true; + zitadel.filter.v2.IDFilter key_id_filter = 1; + zitadel.filter.v2.IDFilter user_id_filter = 2; + zitadel.filter.v2.IDFilter organization_id_filter = 3; + zitadel.filter.v2.TimestampFilter created_date_filter = 4; + zitadel.filter.v2.TimestampFilter expiration_date_filter = 5; + } +} + +enum KeyFieldName { + KEY_FIELD_NAME_UNSPECIFIED = 0; + KEY_FIELD_NAME_CREATED_DATE = 1; + KEY_FIELD_NAME_ID = 2; + KEY_FIELD_NAME_USER_ID = 3; + KEY_FIELD_NAME_ORGANIZATION_ID = 4; + KEY_FIELD_NAME_KEY_EXPIRATION_DATE = 5; +} diff --git a/proto/zitadel/user/v2/pat.proto b/proto/zitadel/user/v2/pat.proto new file mode 100644 index 0000000000..1d24c4c496 --- /dev/null +++ b/proto/zitadel/user/v2/pat.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package zitadel.user.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "zitadel/filter/v2/filter.proto"; + +message PersonalAccessToken { + // The timestamp of the personal access token creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change of the personal access token. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The unique identifier of the personal access token. + string id = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the user the personal access token belongs to. + string user_id = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The unique identifier of the organization the personal access token belongs to. + string organization_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The personal access tokens expiration date. + google.protobuf.Timestamp expiration_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message PersonalAccessTokensSearchFilter { + oneof filter { + option (validate.required) = true; + zitadel.filter.v2.IDFilter token_id_filter = 1; + zitadel.filter.v2.IDFilter user_id_filter = 2; + zitadel.filter.v2.IDFilter organization_id_filter = 3; + zitadel.filter.v2.TimestampFilter created_date_filter = 4; + zitadel.filter.v2.TimestampFilter expiration_date_filter = 5; + } +} + +enum PersonalAccessTokenFieldName { + PERSONAL_ACCESS_TOKEN_FIELD_NAME_UNSPECIFIED = 0; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE = 1; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_ID = 2; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_USER_ID = 3; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_ORGANIZATION_ID = 4; + PERSONAL_ACCESS_TOKEN_FIELD_NAME_EXPIRATION_DATE = 5; +} + diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 44d25c07b3..3fc81836d6 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -2,6 +2,14 @@ syntax = "proto3"; package zitadel.user.v2; +import "google/protobuf/timestamp.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + import "zitadel/object/v2/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/user/v2/auth.proto"; @@ -10,13 +18,10 @@ import "zitadel/user/v2/phone.proto"; import "zitadel/user/v2/idp.proto"; import "zitadel/user/v2/password.proto"; import "zitadel/user/v2/user.proto"; +import "zitadel/user/v2/key.proto"; +import "zitadel/user/v2/pat.proto"; import "zitadel/user/v2/query.proto"; -import "google/api/annotations.proto"; -import "google/api/field_behavior.proto"; -import "google/protobuf/duration.proto"; -import "google/protobuf/struct.proto"; -import "protoc-gen-openapiv2/options/annotations.proto"; -import "validate/validate.proto"; +import "zitadel/filter/v2/filter.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; @@ -85,9 +90,9 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { } } responses: { - key: "403"; + key: "400"; value: { - description: "Returned when the user does not have permission to access the resource."; + description: "The request is malformed."; schema: { json_schema: { ref: "#/definitions/rpcStatus"; @@ -96,9 +101,20 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { } } responses: { - key: "404"; + key: "401"; value: { - description: "Returned when the resource does not exist."; + description: "Returned when the user is not authenticated."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; schema: { json_schema: { ref: "#/definitions/rpcStatus"; @@ -110,8 +126,51 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { service UserService { + // Create a User + // + // Create a new human or machine user in the specified organization. + // + // Required permission: + // - user.write + rpc CreateUser (CreateUserRequest) returns (CreateUserResponse) { + option (google.api.http) = { + // The /new path segment does not follow Zitadels API design. + // The only reason why it is used here is to avoid a conflict with the ListUsers endpoint, which already handles POST /v2/users. + post: "/v2/users/new" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "OK"; + } + }; + responses: { + key: "409" + value: { + description: "The user already exists."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + // Create a new human user // + // Deprecated: Use [CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a new user of type human instead. + // // Create/import a new user with the type human. The newly created user will get a verification email if either the email address is not marked as verified and you did not request the verification to be returned. rpc AddHumanUser (AddHumanUserRequest) returns (AddHumanUserResponse) { option (google.api.http) = { @@ -125,11 +184,12 @@ service UserService { org_field: "organization" } http_response: { - success_code: 201 + success_code: 200 } }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { @@ -163,6 +223,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -204,6 +270,8 @@ service UserService { // Change the user email // + // Deprecated: [Update the users email field](apis/resources/user_service_v2/user-service-update-user.api.mdx). + // // Change the email address of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by email.. rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) { option (google.api.http) = { @@ -218,18 +286,23 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Resend code to verify user email - // - // Resend code to verify user email. rpc ResendEmailCode (ResendEmailCodeRequest) returns (ResendEmailCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/email/resend" @@ -249,12 +322,16 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Send code to verify user email - // - // Send code to verify user email. rpc SendEmailCode (SendEmailCodeRequest) returns (SendEmailCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/email/send" @@ -274,6 +351,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -299,11 +382,19 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Set the user phone // + // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx). + // // Set the phone number of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by sms.. rpc SetPhone(SetPhoneRequest) returns (SetPhoneResponse) { option (google.api.http) = { @@ -318,18 +409,27 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } - // Remove the user phone + // Delete the user phone // - // Remove the user phone + // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx) to remove the phone number. + // + // Delete the phone number of a user. rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) { option (google.api.http) = { delete: "/v2/users/{user_id}/phone" @@ -343,20 +443,23 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - summary: "Delete the user phone"; - description: "Delete the phone number of a user." + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Resend code to verify user phone - // - // Resend code to verify user phone. rpc ResendPhoneCode (ResendPhoneCodeRequest) returns (ResendPhoneCodeResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/phone/resend" @@ -376,6 +479,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -401,15 +510,27 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } - // Update User + + // Update a User // - // Update all information from a user.. - rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { + // Partially update an existing user. + // If you change the users email or phone, you can specify how the ownership should be verified. + // If you change the users password, you can specify if the password should be changed again on the users next login. + // + // Required permission: + // - user.write + rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) { option (google.api.http) = { - put: "/v2/users/human/{user_id}" + patch: "/v2/users/{user_id}" body: "*" }; @@ -426,6 +547,62 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + } + responses: { + key: "409" + value: { + description: "The user already exists."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Update Human User + // + // Deprecated: Use [UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type human instead. + // + // Update all information from a user.. + rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { + option (google.api.http) = { + put: "/v2/users/human/{user_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -451,6 +628,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -476,6 +659,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -501,6 +690,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -526,6 +721,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -550,6 +751,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -574,6 +781,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -598,6 +811,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -622,6 +841,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -670,6 +895,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -694,6 +925,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -718,6 +955,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -743,6 +986,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -767,6 +1016,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -791,6 +1046,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -814,6 +1075,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -838,6 +1105,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -861,6 +1134,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -885,6 +1164,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -908,6 +1193,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -958,6 +1249,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "Intent ID does not exist."; + } + } }; } @@ -983,6 +1280,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1033,6 +1336,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1058,11 +1367,19 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } // Change password // + // Deprecated: [Update the users password](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. + // // Change the password of a user with either a verification code or the current password.. rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { option (google.api.http) = { @@ -1077,12 +1394,19 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + deprecated: true; responses: { key: "200" value: { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1155,6 +1479,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1183,6 +1513,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1209,6 +1545,12 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } @@ -1234,9 +1576,289 @@ service UserService { description: "OK"; } }; + responses: { + key: "404"; + value: { + description: "User ID does not exist."; + } + } }; } + // Add a Users Secret + // + // Generates a client secret for the user. + // The client id is the users username. + // If the user already has a secret, it is overwritten. + // Only users of type machine can have a secret. + // + // Required permission: + // - user.write + rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/secret" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was successfully generated."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Users Secret + // + // Remove the current client ID and client secret from a machine user. + // + // Required permission: + // - user.write + rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/secret" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was either successfully removed or it didn't exist in the first place."; + } + }; + }; + } + + // Add a Key + // + // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. + // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have keys. + // + // Required permission: + // - user.write + rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/keys" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Key + // + // Remove a machine users key by the given key ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/keys/{key_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was either successfully removed or it not found in the first place."; + } + }; + }; + } + + // Search Keys + // + // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { + option (google.api.http) = { + post: "/v2/users/keys/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all machine user keys matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Add a Personal Access Token + // + // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. + // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have personal access tokens. + // + // Required permission: + // - user.write + rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/pats" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Personal Access Token + // + // Removes a machine users personal access token by the given token ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/pats/{token_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was either successfully removed or it was not found in the first place."; + } + }; + }; + } + + // Search Personal Access Tokens + // + // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { + option (google.api.http) = { + post: "/v2/users/pats/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all personal access tokens matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } } message AddHumanUserRequest{ @@ -1296,6 +1918,149 @@ message AddHumanUserResponse { optional string phone_code = 4; } + +message CreateUserRequest{ + message Human { + // Set the users profile information. + SetHumanProfile profile = 1 [ + (validate.rules).message.required = true, + (google.api.field_behavior) = REQUIRED + ]; + // Set the users email address and optionally send a verification email. + SetHumanEmail email = 2 [ + (validate.rules).message.required = true, + (google.api.field_behavior) = REQUIRED + ]; + // Set the users phone number and optionally send a verification SMS. + optional SetHumanPhone phone = 3; + // Set the users initial password and optionally require the user to set a new password. + oneof password_type { + Password password = 4; + HashedPassword hashed_password = 5; + } + // Create the user with a list of links to identity providers. + // This can be useful in migration-scenarios. + // For example, if a user already has an account in an external identity provider or another Zitadel instance, an IDP link allows the user to authenticate as usual. + // Sessions, second factors, hardware keys registered externally are still available for authentication. + // Use the following endpoints to manage identity provider links: + // - [AddIDPLink](apis/resources/user_service_v2/user-service-add-idp-link.api.mdx) + // - [RemoveIDPLink](apis/resources/user_service_v2/user-service-remove-idp-link.api.mdx) + repeated IDPLink idp_links = 7; + // An Implementation of RFC 6238 is used, with HMAC-SHA-1 and time-step of 30 seconds. + // Currently no other options are supported, and if anything different is used the validation will fail. + optional string totp_secret = 8 [ + (validate.rules).string = {min_len: 1 max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\""; + } + ]; + } + message Machine { + // The machine users name is a human readable field that helps identifying the user. + string name = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"Acceptance Test User\""; + } + ]; + // The description is a field that helps to remember the purpose of the user. + optional string description = 2 [ + (validate.rules).string = {min_len: 1, max_len: 500}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 500, + example: "\"The user calls the session API in the continuous integration pipeline for acceptance tests.\""; + } + ]; + } + // The unique identifier of the organization the user belongs to. + string organization_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; + // The ID is a unique identifier for the user in the instance. + // If not specified, it will be generated. + // You can set your own user id that is unique within the instance. + // This is useful in migration scenarios, for example if the user already has an ID in another Zitadel system. + // If not specified, it will be generated. + // It can't be changed after creation. + optional string user_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"163840776835432345\""; + } + ]; + + // The username is a unique identifier for the user in the organization. + // If not specified, Zitadel sets the username to the email for users of type human and to the user_id for users of type machine. + // It is used to identify the user in the organization and can be used for login. + optional string username = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"minnie-mouse\""; + } + ]; + + // The type of the user. + oneof user_type { + option (validate.required) = true; + // Users of type human are users that are meant to be used by a person. + // They can log in interactively using a login UI. + // By default, new users will receive a verification email and, if a phone is configured, a verification SMS. + // To make sure these messages are sent, configure and activate valid SMTP and Twilio configurations. + // Read more about your options for controlling this behaviour in the email and phone field documentations. + Human human = 4; + // Users of type machine are users that are meant to be used by a machine. + // In order to authenticate, [add a secret](apis/resources/user_service_v2/user-service-add-secret.api.mdx), [a key](apis/resources/user_service_v2/user-service-add-key.api.mdx) or [a personal access token](apis/resources/user_service_v2/user-service-add-personal-access-token.api.mdx) to the user. + // Tokens generated for new users of type machine will be of an opaque Bearer type. + // You can change the users token type to JWT by using the [management v1 service method UpdateMachine](apis/resources/mgmt/management-service-update-machine.api.mdx). + Machine machine = 5; + } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"organizationId\":\"69629026806489455\",\"userId\":\"163840776835432345\",\"username\":\"minnie-mouse\",\"human\":{\"profile\":{\"givenName\":\"Minnie\",\"familyName\":\"Mouse\",\"nickName\":\"Mini\",\"displayName\":\"Minnie Mouse\",\"preferredLanguage\":\"en\",\"gender\":\"GENDER_FEMALE\"},\"email\":{\"email\":\"mini@mouse.com\",\"sendCode\":{\"urlTemplate\":\"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"}},\"phone\":{\"phone\":\"+41791234567\",\"isVerified\":true},\"password\":{\"password\":\"Secr3tP4ssw0rd!\",\"changeRequired\":true},\"idpLinks\":[{\"idpId\":\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\",\"userId\":\"6516849804890468048461403518\",\"userName\":\"user@external.com\"}],\"totpSecret\":\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\"}}"; + }; +} + +message CreateUserResponse { + // The unique identifier of the newly created user. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the user creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // The email verification code if it was requested by setting the email verification to return_code. + optional string email_code = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"XTA6BC\""; + } + ]; + // The phone verification code if it was requested by setting the phone verification to return_code. + optional string phone_code = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"XTA6BC\""; + } + ]; +} + message GetUserByIDRequest { reserved 2; reserved "organization"; @@ -1550,6 +2315,142 @@ message DeleteUserResponse { zitadel.object.v2.Details details = 1; } + +message UpdateUserRequest{ + message Human { + message Profile { + // The given name is the first name of the user. + // For example, it can be used to personalize notifications and login UIs. + optional string given_name = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Minnie\""; + } + ]; + // The family name is the last name of the user. + // For example, it can be used to personalize user interfaces and notifications. + optional string family_name = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Mouse\""; + } + ]; + // The nick name is the users short name. + // For example, it can be used to personalize user interfaces and notifications. + optional string nick_name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Mini\""; + } + ]; + // The display name is how a user should primarily be displayed in lists. + // It can also for example be used to personalize user interfaces and notifications. + optional string display_name = 4 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"Minnie Mouse\""; + } + ]; + // The users preferred language is the language that systems should use to interact with the user. + // It has the format of a [BCP-47 language tag](https://datatracker.ietf.org/doc/html/rfc3066). + // It is used by Zitadel where no higher prioritized preferred language can be used. + // For example, browser settings can overwrite a users preferred_language. + // Notification messages and standard login UIs use the users preferred language if it is supported and allowed on the instance. + // Else, the default language of the instance is used. + optional string preferred_language = 5 [ + (validate.rules).string = {min_len: 1, max_len: 10}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 10; + example: "\"en-US\""; + } + ]; + // The users gender can for example be used to personalize user interfaces and notifications. + optional Gender gender = 6 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"GENDER_FEMALE\""; + } + ]; + } + // Change the users profile information + optional Profile profile = 1; + // Change the users email address and/or trigger a verification email + optional SetHumanEmail email = 2; + // Change the users phone number and/or trigger a verification SMS + // To delete the users phone number, leave the phone field empty and omit the verification field. + optional SetHumanPhone phone = 3; + // Change the users password. + // You can optionally require the current password or the verification code to be correct. + optional SetPassword password = 4; + } + message Machine { + // The machine users name is a human readable field that helps identifying the user. + optional string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Acceptance Test User\""; + } + ]; + // The description is a field that helps to remember the purpose of the user. + optional string description = 2 [ + (validate.rules).string = {max_len: 500}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"The user calls the session API in the continuous integration pipeline for acceptance tests.\""; + } + ]; + } + // The user id is the users unique identifier in the instance. + // It can't be changed. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // Set a new username that is unique within the instance. + // Beware that active tokens and sessions are invalidated when the username is changed. + optional string username = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"minnie-mouse\""; + } + ]; + // Change type specific properties of the user. + oneof user_type { + Human human = 3; + Machine machine = 4; + } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"username\":\"minnie-mouse\",\"human\":{\"profile\":{\"givenName\":\"Minnie\",\"familyName\":\"Mouse\",\"displayName\":\"Minnie Mouse\"},\"email\":{\"email\":\"mini@mouse.com\",\"returnCode\":{}},\"phone\":{\"phone\":\"+41791234567\",\"isVerified\":true},\"password\":{\"password\":{\"password\":\"Secr3tP4ssw0rd!\",\"changeRequired\":true},\"verificationCode\":\"SKJd342k\"},\"totpSecret\":\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\"}}"; + }; +} + +message UpdateUserResponse { + // The timestamp of the change of the user. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // In case the email verification was set to return_code, the code will be returned + optional string email_code = 2; + // In case the phone verification was set to return_code, the code will be returned + optional string phone_code = 3; +} + message UpdateHumanUserRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -1595,7 +2496,6 @@ message DeactivateUserResponse { zitadel.object.v2.Details details = 1; } - message ReactivateUserRequest { string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -2384,3 +3284,237 @@ message HumanMFAInitSkippedRequest { message HumanMFAInitSkippedResponse { zitadel.object.v2.Details details = 1; } + +message AddSecretRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; +} + +message AddSecretResponse { + // The timestamp of the secret creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The client secret. + // Store this secret in a secure place. + // It is not possible to retrieve it again. + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"WoYLHB23HAZaCSxeMJGEzbu8urHICVdFp2IegVr6Q5U4lZHKAtRvmaalNDWfCuHV\""; + } + ]; +} + +message RemoveSecretRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; +} + +message RemoveSecretResponse { + // The timestamp of the secret deletion. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message AddKeyRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The date the key will expire and no logins will be possible anymore. + google.protobuf.Timestamp expiration_date = 2 [ + (validate.rules).timestamp.required = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + } + ]; + // Optionally provide a public key of your own generated RSA private key. + bytes public_key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1...\""; + } + ]; +} + +message AddKeyResponse { + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The keys ID. + string key_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; + // The key which is usable to authenticate against the API. + bytes key_content = 3; +} + + +message RemoveKeyRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The keys ID. + string key_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message RemoveKeyResponse { + // The timestamp of the key deletion. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ListKeysRequest { + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional KeyFieldName sorting_column = 2 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"KEY_FIELD_NAME_CREATED_DATE\"" + } + ]; + // Define the criteria to query for. + repeated KeysSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":10,\"asc\":false},\"sortingColumn\":\"KEY_FIELD_NAME_CREATED_DATE\",\"filters\":[{\"andFilter\":{\"filters\":[{\"organizationIdFilter\":{\"organizationId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"notFilter\":{\"filter\":{\"userIdFilter\":{\"userId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}}},{\"orFilter\":{\"filters\":[{\"keyIdFilter\":{\"keyId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"keyIdFilter\":{\"keyId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}]}}]}}]}"; + }; +} + +message ListKeysResponse { + zitadel.filter.v2.PaginationResponse pagination = 1; + repeated Key result = 2; +} + +message AddPersonalAccessTokenRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The timestamp when the token will expire. + google.protobuf.Timestamp expiration_date = 2 [ + (validate.rules).timestamp.required = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + } + ]; +} + +message AddPersonalAccessTokenResponse { + // The timestamp of the personal access token creation. + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The tokens ID. + string token_id = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"163840776835432345\""; + } + ]; + // The personal access token that can be used to authenticate against the API + string token = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c\""; + } + ]; +} + +message RemovePersonalAccessTokenRequest { + // The users resource ID. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // The tokens ID. + string token_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message RemovePersonalAccessTokenResponse { + // The timestamp of the personal access token deletion. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + + +message ListPersonalAccessTokensRequest { + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional PersonalAccessTokenFieldName sorting_column = 2 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE\"" + } + ]; + // Define the criteria to query for. + repeated PersonalAccessTokensSearchFilter filters = 3; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"pagination\":{\"offset\":0,\"limit\":10,\"asc\":false},\"sortingColumn\":\"PERSONAL_ACCESS_TOKEN_FIELD_NAME_CREATED_DATE\",\"filters\":[{\"andFilter\":{\"filters\":[{\"organizationIdFilter\":{\"organizationId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"notFilter\":{\"filter\":{\"userIdFilter\":{\"userId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}}},{\"orFilter\":{\"filters\":[{\"personalAccessTokenIdFilter\":{\"personalAccessTokenId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}},{\"personalAccessTokenIdFilter\":{\"personalAccessTokenId\":\"163840776835432345\",\"method\":\"TEXT_FILTER_METHOD_EQUALS\"}}]}}]}}]}"; + }; +} + +message ListPersonalAccessTokensResponse { + zitadel.filter.v2.PaginationResponse pagination = 1; + repeated PersonalAccessToken result = 2; +} From 1a80e265020844a08586691bea135a69796745c4 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 4 Jun 2025 11:04:52 +0200 Subject: [PATCH 085/123] fix(console): org context for V2 user creation (#9971) # Which Problems Are Solved This PR addresses a bug in Console V2 APIs, specifically when the feature toggle is enabled, which caused incorrect organization context assignment during new user creation. Co-authored-by: Ramon --- .../user-create/user-create-v2/user-create-v2.component.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts index 9fd765264d..b92c112357 100644 --- a/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts +++ b/console/src/app/pages/users/user-create/user-create-v2/user-create-v2.component.ts @@ -32,6 +32,7 @@ import { withLatestFromSynchronousFix } from 'src/app/utils/withLatestFromSynchr import { PasswordComplexityValidatorFactoryService } from 'src/app/services/password-complexity-validator-factory.service'; import { NewFeatureService } from 'src/app/services/new-feature.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { GrpcAuthService } from 'src/app/services/grpc-auth.service'; type PwdForm = ReturnType; type AuthenticationFactor = @@ -65,6 +66,7 @@ export class UserCreateV2Component implements OnInit { private readonly destroyRef: DestroyRef, private readonly route: ActivatedRoute, protected readonly location: Location, + private readonly authService: GrpcAuthService, ) { this.userForm = this.buildUserForm(); @@ -180,9 +182,12 @@ export class UserCreateV2Component implements OnInit { private async createUserV2Try(authenticationFactor: AuthenticationFactor) { this.loading.set(true); + const org = await this.authService.getActiveOrg(); + const userValues = this.userForm.getRawValue(); const humanReq: MessageInitShape = { + organization: { org: { case: 'orgId', value: org.id } }, username: userValues.username, profile: { givenName: userValues.givenName, From 839c761357f17f51ede7f0a3f0f4a4c96a3863ab Mon Sep 17 00:00:00 2001 From: AnthonyKot Date: Wed, 4 Jun 2025 11:26:53 +0200 Subject: [PATCH 086/123] fix(FE): allow only enabled factors to be displayed on user page (#9313) # Which Problems Are Solved - Hides for users MFA options are not allowed by org policy. - Fix for "ng test" across "console" # How the Problems Are Solved - Before displaying MFA options we call "listMyMultiFactors" from parent component to filter MFA allowed by org # Additional Changes - Dependency Injection was fixed around ng unit tests # Additional Context admin view Screenshot 2025-02-06 at 00 26 50 user view Screenshot 2025-02-06 at 00 27 16 test Screenshot 2025-02-06 at 00 01 36 The issue: https://github.com/zitadel/zitadel/issues/9176 The bug report: https://discord.com/channels/927474939156643850/1307006457815896094 --------- Co-authored-by: a k Co-authored-by: a k Co-authored-by: a k Co-authored-by: Ramon --- console/package.json | 1 + .../oidc-configuration.component.spec.ts | 10 +- .../modules/domains/domains.component.spec.ts | 10 +- .../filter-project.component.spec.ts | 10 +- .../app/modules/input/input.directive.spec.ts | 45 +++++- .../app/modules/label/label.component.spec.ts | 10 +- .../login-policy/login-policy.component.ts | 7 +- .../message-texts.component.spec.ts | 10 +- .../notification-policy.component.spec.ts | 10 +- ...word-dialog-sms-provider.component.spec.ts | 10 +- .../provider-github-es.component.spec.ts | 10 +- ...vider-gitlab-self-hosted.component.spec.ts | 10 +- .../provider-gitlab.component.spec.ts | 10 +- .../show-token-dialog.component.spec.ts | 10 +- .../smtp-table/smtp-table.component.spec.ts | 10 +- .../add-action-dialog.component.spec.ts | 10 +- .../add-flow-dialog.component.spec.ts | 10 +- .../auth-factor-dialog.component.html | 13 +- .../auth-factor-dialog.component.ts | 14 +- .../auth-user-mfa.component.spec.ts | 133 +++++++++++++++++- .../auth-user-mfa/auth-user-mfa.component.ts | 127 +++++++++-------- .../user-detail/contact/contact.component.ts | 8 +- .../detail-form-machine.component.spec.ts | 10 +- .../passwordless.component.spec.ts | 10 +- console/src/app/services/grpc-auth.service.ts | 45 ------ console/src/app/services/new-auth.service.ts | 37 +++++ console/yarn.lock | 25 +++- 27 files changed, 411 insertions(+), 204 deletions(-) diff --git a/console/package.json b/console/package.json index 77a8a40147..0095360017 100644 --- a/console/package.json +++ b/console/package.json @@ -82,6 +82,7 @@ "jasmine-spec-reporter": "~7.0.0", "karma": "^6.4.4", "karma-chrome-launcher": "^3.2.0", + "karma-coverage": "^2.2.1", "karma-coverage-istanbul-reporter": "^3.0.3", "karma-jasmine": "^5.1.0", "karma-jasmine-html-reporter": "^2.1.0", diff --git a/console/src/app/components/oidc-configuration/oidc-configuration.component.spec.ts b/console/src/app/components/oidc-configuration/oidc-configuration.component.spec.ts index 6155ba5693..e99aee357c 100644 --- a/console/src/app/components/oidc-configuration/oidc-configuration.component.spec.ts +++ b/console/src/app/components/oidc-configuration/oidc-configuration.component.spec.ts @@ -1,16 +1,16 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { QuickstartComponent } from './quickstart.component'; +import { OIDCConfigurationComponent } from './oidc-configuration.component'; describe('QuickstartComponent', () => { - let component: QuickstartComponent; - let fixture: ComponentFixture; + let component: OIDCConfigurationComponent; + let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ - declarations: [QuickstartComponent], + declarations: [OIDCConfigurationComponent], }); - fixture = TestBed.createComponent(QuickstartComponent); + fixture = TestBed.createComponent(OIDCConfigurationComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/domains/domains.component.spec.ts b/console/src/app/modules/domains/domains.component.spec.ts index 127bae48b5..f3d75fb12b 100644 --- a/console/src/app/modules/domains/domains.component.spec.ts +++ b/console/src/app/modules/domains/domains.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { OrgDomainsComponent } from './org-domains.component'; +import { DomainsComponent } from './domains.component'; describe('OrgDomainsComponent', () => { - let component: OrgDomainsComponent; - let fixture: ComponentFixture; + let component: DomainsComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [OrgDomainsComponent], + declarations: [DomainsComponent], }).compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(OrgDomainsComponent); + fixture = TestBed.createComponent(DomainsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/filter-project/filter-project.component.spec.ts b/console/src/app/modules/filter-project/filter-project.component.spec.ts index 0ed0436db8..ff465d8705 100644 --- a/console/src/app/modules/filter-project/filter-project.component.spec.ts +++ b/console/src/app/modules/filter-project/filter-project.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { FilterUserComponent } from './filter-user.component'; +import { FilterProjectComponent } from './filter-project.component'; describe('FilterUserComponent', () => { - let component: FilterUserComponent; - let fixture: ComponentFixture; + let component: FilterProjectComponent; + let fixture: ComponentFixture; beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [FilterUserComponent], + declarations: [FilterProjectComponent], }).compileComponents(); }); beforeEach(() => { - fixture = TestBed.createComponent(FilterUserComponent); + fixture = TestBed.createComponent(FilterProjectComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/input/input.directive.spec.ts b/console/src/app/modules/input/input.directive.spec.ts index 463fed5431..46544ca096 100644 --- a/console/src/app/modules/input/input.directive.spec.ts +++ b/console/src/app/modules/input/input.directive.spec.ts @@ -1,8 +1,49 @@ +import { Component, ElementRef, NgZone } from '@angular/core'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; import { InputDirective } from './input.directive'; +import { Platform } from '@angular/cdk/platform'; +import { NgControl, NgForm, FormGroupDirective } from '@angular/forms'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { AutofillMonitor } from '@angular/cdk/text-field'; +import { MatFormField } from '@angular/material/form-field'; +import { MAT_INPUT_VALUE_ACCESSOR } from '@angular/material/input'; +import { of } from 'rxjs'; +import { By } from '@angular/platform-browser'; + +@Component({ + template: ``, +}) +class TestHostComponent {} describe('InputDirective', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [InputDirective, TestHostComponent], + providers: [ + { provide: ElementRef, useValue: new ElementRef(document.createElement('input')) }, + Platform, + { provide: NgControl, useValue: null }, + { provide: NgForm, useValue: null }, + { provide: FormGroupDirective, useValue: null }, + ErrorStateMatcher, + { provide: MAT_INPUT_VALUE_ACCESSOR, useValue: null }, + { + provide: AutofillMonitor, + useValue: { monitor: () => of(), stopMonitoring: () => {} }, + }, + NgZone, + { provide: MatFormField, useValue: null }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + fixture.detectChanges(); + }); + it('should create an instance', () => { - const directive = new InputDirective(); - expect(directive).toBeTruthy(); + const directiveEl = fixture.debugElement.query(By.directive(InputDirective)); + expect(directiveEl).toBeTruthy(); }); }); diff --git a/console/src/app/modules/label/label.component.spec.ts b/console/src/app/modules/label/label.component.spec.ts index 2b29b30873..e719aa3775 100644 --- a/console/src/app/modules/label/label.component.spec.ts +++ b/console/src/app/modules/label/label.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { AvatarComponent } from './avatar.component'; +import { LabelComponent } from './label.component'; describe('AvatarComponent', () => { - let component: AvatarComponent; - let fixture: ComponentFixture; + let component: LabelComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [AvatarComponent], + declarations: [LabelComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(AvatarComponent); + fixture = TestBed.createComponent(LabelComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/policies/login-policy/login-policy.component.ts b/console/src/app/modules/policies/login-policy/login-policy.component.ts index b4e4557f00..5dd85b8b38 100644 --- a/console/src/app/modules/policies/login-policy/login-policy.component.ts +++ b/console/src/app/modules/policies/login-policy/login-policy.component.ts @@ -2,14 +2,12 @@ import { Component, Injector, Input, OnDestroy, OnInit, Type } from '@angular/co import { AbstractControl, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { Duration } from 'google-protobuf/google/protobuf/duration_pb'; -import { firstValueFrom, forkJoin, from, Observable, of, Subject, take } from 'rxjs'; +import { forkJoin, from, of, Subject, take } from 'rxjs'; import { GetLoginPolicyResponse as AdminGetLoginPolicyResponse, UpdateLoginPolicyRequest, - UpdateLoginPolicyResponse, } from 'src/app/proto/generated/zitadel/admin_pb'; import { - AddCustomLoginPolicyRequest, GetLoginPolicyResponse as MgmtGetLoginPolicyResponse, UpdateCustomLoginPolicyRequest, } from 'src/app/proto/generated/zitadel/management_pb'; @@ -24,8 +22,7 @@ import { InfoSectionType } from '../../info-section/info-section.component'; import { WarnDialogComponent } from '../../warn-dialog/warn-dialog.component'; import { PolicyComponentServiceType } from '../policy-component-types.enum'; import { LoginMethodComponentType } from './factor-table/factor-table.component'; -import { catchError, map, takeUntil } from 'rxjs/operators'; -import { error } from 'console'; +import { map, takeUntil } from 'rxjs/operators'; import { LoginPolicyService } from '../../../services/login-policy.service'; const minValueValidator = (minValue: number) => (control: AbstractControl) => { diff --git a/console/src/app/modules/policies/message-texts/message-texts.component.spec.ts b/console/src/app/modules/policies/message-texts/message-texts.component.spec.ts index 71dd427da5..e5569f9ed3 100644 --- a/console/src/app/modules/policies/message-texts/message-texts.component.spec.ts +++ b/console/src/app/modules/policies/message-texts/message-texts.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { LoginPolicyComponent } from './login-policy.component'; +import { MessageTextsComponent } from './message-texts.component'; describe('LoginPolicyComponent', () => { - let component: LoginPolicyComponent; - let fixture: ComponentFixture; + let component: MessageTextsComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [LoginPolicyComponent], + declarations: [MessageTextsComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(LoginPolicyComponent); + fixture = TestBed.createComponent(MessageTextsComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/policies/notification-policy/notification-policy.component.spec.ts b/console/src/app/modules/policies/notification-policy/notification-policy.component.spec.ts index c323d884f1..f529e143a5 100644 --- a/console/src/app/modules/policies/notification-policy/notification-policy.component.spec.ts +++ b/console/src/app/modules/policies/notification-policy/notification-policy.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { PasswordComplexityPolicyComponent } from './password-complexity-policy.component'; +import { NotificationPolicyComponent } from './notification-policy.component'; describe('PasswordComplexityPolicyComponent', () => { - let component: PasswordComplexityPolicyComponent; - let fixture: ComponentFixture; + let component: NotificationPolicyComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [PasswordComplexityPolicyComponent], + declarations: [NotificationPolicyComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(PasswordComplexityPolicyComponent); + fixture = TestBed.createComponent(NotificationPolicyComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/policies/notification-sms-provider/password-dialog-sms-provider/password-dialog-sms-provider.component.spec.ts b/console/src/app/modules/policies/notification-sms-provider/password-dialog-sms-provider/password-dialog-sms-provider.component.spec.ts index 034bbe8de0..b009b03757 100644 --- a/console/src/app/modules/policies/notification-sms-provider/password-dialog-sms-provider/password-dialog-sms-provider.component.spec.ts +++ b/console/src/app/modules/policies/notification-sms-provider/password-dialog-sms-provider/password-dialog-sms-provider.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { PasswordDialogComponent } from './password-dialog-sms-provider.component'; +import { PasswordDialogSMSProviderComponent } from './password-dialog-sms-provider.component'; describe('PasswordDialogComponent', () => { - let component: PasswordDialogComponent; - let fixture: ComponentFixture; + let component: PasswordDialogSMSProviderComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [PasswordDialogComponent], + declarations: [PasswordDialogSMSProviderComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(PasswordDialogComponent); + fixture = TestBed.createComponent(PasswordDialogSMSProviderComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/providers/provider-github-es/provider-github-es.component.spec.ts b/console/src/app/modules/providers/provider-github-es/provider-github-es.component.spec.ts index 0086bf0ce3..304004d0cf 100644 --- a/console/src/app/modules/providers/provider-github-es/provider-github-es.component.spec.ts +++ b/console/src/app/modules/providers/provider-github-es/provider-github-es.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ProviderOAuthComponent } from './provider-oauth.component'; +import { ProviderGithubESComponent } from './provider-github-es.component'; describe('ProviderOAuthComponent', () => { - let component: ProviderOAuthComponent; - let fixture: ComponentFixture; + let component: ProviderGithubESComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ProviderOAuthComponent], + declarations: [ProviderGithubESComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(ProviderOAuthComponent); + fixture = TestBed.createComponent(ProviderGithubESComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/providers/provider-gitlab-self-hosted/provider-gitlab-self-hosted.component.spec.ts b/console/src/app/modules/providers/provider-gitlab-self-hosted/provider-gitlab-self-hosted.component.spec.ts index 3b6fdadce3..5a0bbf6d08 100644 --- a/console/src/app/modules/providers/provider-gitlab-self-hosted/provider-gitlab-self-hosted.component.spec.ts +++ b/console/src/app/modules/providers/provider-gitlab-self-hosted/provider-gitlab-self-hosted.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ProviderGoogleComponent } from './provider-google.component'; +import { ProviderGitlabSelfHostedComponent } from './provider-gitlab-self-hosted.component'; describe('ProviderGoogleComponent', () => { - let component: ProviderGoogleComponent; - let fixture: ComponentFixture; + let component: ProviderGitlabSelfHostedComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ProviderGoogleComponent], + declarations: [ProviderGitlabSelfHostedComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(ProviderGoogleComponent); + fixture = TestBed.createComponent(ProviderGitlabSelfHostedComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/providers/provider-gitlab/provider-gitlab.component.spec.ts b/console/src/app/modules/providers/provider-gitlab/provider-gitlab.component.spec.ts index 3b6fdadce3..7b5becd782 100644 --- a/console/src/app/modules/providers/provider-gitlab/provider-gitlab.component.spec.ts +++ b/console/src/app/modules/providers/provider-gitlab/provider-gitlab.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ProviderGoogleComponent } from './provider-google.component'; +import { ProviderGitlabComponent } from './provider-gitlab.component'; describe('ProviderGoogleComponent', () => { - let component: ProviderGoogleComponent; - let fixture: ComponentFixture; + let component: ProviderGitlabComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ProviderGoogleComponent], + declarations: [ProviderGitlabComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(ProviderGoogleComponent); + fixture = TestBed.createComponent(ProviderGitlabComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/show-token-dialog/show-token-dialog.component.spec.ts b/console/src/app/modules/show-token-dialog/show-token-dialog.component.spec.ts index de74dc7522..35f4dbcf77 100644 --- a/console/src/app/modules/show-token-dialog/show-token-dialog.component.spec.ts +++ b/console/src/app/modules/show-token-dialog/show-token-dialog.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { ShowKeyDialogComponent } from './show-key-dialog.component'; +import { ShowTokenDialogComponent } from './show-token-dialog.component'; describe('ShowKeyDialogComponent', () => { - let component: ShowKeyDialogComponent; - let fixture: ComponentFixture; + let component: ShowTokenDialogComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ShowKeyDialogComponent], + declarations: [ShowTokenDialogComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(ShowKeyDialogComponent); + fixture = TestBed.createComponent(ShowTokenDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/modules/smtp-table/smtp-table.component.spec.ts b/console/src/app/modules/smtp-table/smtp-table.component.spec.ts index 8095d73255..fe4719482c 100644 --- a/console/src/app/modules/smtp-table/smtp-table.component.spec.ts +++ b/console/src/app/modules/smtp-table/smtp-table.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { IdpTableComponent } from './smtp-table.component'; +import { SMTPTableComponent } from './smtp-table.component'; describe('UserTableComponent', () => { - let component: IdpTableComponent; - let fixture: ComponentFixture; + let component: SMTPTableComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [IdpTableComponent], + declarations: [SMTPTableComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(IdpTableComponent); + fixture = TestBed.createComponent(SMTPTableComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.spec.ts b/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.spec.ts index f4b79bee55..b7c7069728 100644 --- a/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.spec.ts +++ b/console/src/app/pages/actions/add-action-dialog/add-action-dialog.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { AddKeyDialogComponent } from './add-key-dialog.component'; +import { AddActionDialogComponent } from './add-action-dialog.component'; describe('AddKeyDialogComponent', () => { - let component: AddKeyDialogComponent; - let fixture: ComponentFixture; + let component: AddActionDialogComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [AddKeyDialogComponent], + declarations: [AddActionDialogComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(AddKeyDialogComponent); + fixture = TestBed.createComponent(AddActionDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.spec.ts b/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.spec.ts index f4b79bee55..ca9b7c4507 100644 --- a/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.spec.ts +++ b/console/src/app/pages/actions/add-flow-dialog/add-flow-dialog.component.spec.ts @@ -1,19 +1,19 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { AddKeyDialogComponent } from './add-key-dialog.component'; +import { AddFlowDialogComponent } from './add-flow-dialog.component'; describe('AddKeyDialogComponent', () => { - let component: AddKeyDialogComponent; - let fixture: ComponentFixture; + let component: AddFlowDialogComponent; + let fixture: ComponentFixture; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [AddKeyDialogComponent], + declarations: [AddFlowDialogComponent], }).compileComponents(); })); beforeEach(() => { - fixture = TestBed.createComponent(AddKeyDialogComponent); + fixture = TestBed.createComponent(AddFlowDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); }); diff --git a/console/src/app/pages/users/user-detail/auth-user-detail/auth-factor-dialog/auth-factor-dialog.component.html b/console/src/app/pages/users/user-detail/auth-user-detail/auth-factor-dialog/auth-factor-dialog.component.html index e36ec2bcbb..22a4498090 100644 --- a/console/src/app/pages/users/user-detail/auth-user-detail/auth-factor-dialog/auth-factor-dialog.component.html +++ b/console/src/app/pages/users/user-detail/auth-user-detail/auth-factor-dialog/auth-factor-dialog.component.html @@ -1,5 +1,5 @@

- {{ 'USER.MFA.DIALOG.ADD_MFA_TITLE' | translate }} {{ data?.number }} + {{ 'USER.MFA.DIALOG.ADD_MFA_TITLE' | translate }}

@@ -7,6 +7,7 @@
-
-
@@ -304,22 +317,22 @@

{{ 'IDP.DETAIL.DATECREATED' | translate }}

-

- {{ idp.details.creationDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }} +

+ {{ creationDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}

{{ 'IDP.DETAIL.DATECHANGED' | translate }}

-

- {{ idp.details.changeDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }} +

+ {{ changeDate | timestampToDate | localizedDate: 'dd. MMMM YYYY, HH:mm' }}

+ } + > + +
+
+ {children} +
+ + +
+
+
+
+ + + + + + ); +} diff --git a/login/apps/login/src/app/(login)/loginname/page.tsx b/login/apps/login/src/app/(login)/loginname/page.tsx new file mode 100644 index 0000000000..6d8f209572 --- /dev/null +++ b/login/apps/login/src/app/(login)/loginname/page.tsx @@ -0,0 +1,93 @@ +import { DynamicTheme } from "@/components/dynamic-theme"; +import { SignInWithIdp } from "@/components/sign-in-with-idp"; +import { Translated } from "@/components/translated"; +import { UsernameForm } from "@/components/username-form"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { + getActiveIdentityProviders, + getBrandingSettings, + getDefaultOrg, + getLoginSettings, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + + const loginName = searchParams?.loginName; + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + const suffix = searchParams?.suffix; + const submit: boolean = searchParams?.submit === "true"; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + const contextLoginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: organization ?? defaultOrganization, + }).then((resp) => { + return resp.identityProviders; + }); + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+

+ +

+

+ +

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

+ +

+

+ +

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

+ +

+

+ +

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

+ +

+ +

+ +

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

+ +

+ +

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

+ +

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

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

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

+ +

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

+ +

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

+ +

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

+ +

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

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

+

+ +

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

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

+

+ +

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

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

+

+ +

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

+ +

+

+ +

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

+ +

+

+ +

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

+ +

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

+ +

+

+ +

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

+ +

+

+ +

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

+ +

+

+ +

+
+
+ ); +} diff --git a/login/apps/login/src/app/(login)/saml-post/route.ts b/login/apps/login/src/app/(login)/saml-post/route.ts new file mode 100644 index 0000000000..f2834f3884 --- /dev/null +++ b/login/apps/login/src/app/(login)/saml-post/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const url = searchParams.get("url"); + const relayState = searchParams.get("RelayState"); + const samlResponse = searchParams.get("SAMLResponse"); + + if (!url || !relayState || !samlResponse) { + return new NextResponse("Missing required parameters", { status: 400 }); + } + + // Respond with an HTML form that auto-submits via POST + const html = ` + + +
+ + + +
+ + + `; + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); +} diff --git a/login/apps/login/src/app/(login)/signedin/page.tsx b/login/apps/login/src/app/(login)/signedin/page.tsx new file mode 100644 index 0000000000..5b2ed5fbf4 --- /dev/null +++ b/login/apps/login/src/app/(login)/signedin/page.tsx @@ -0,0 +1,141 @@ +import { Alert, AlertType } from "@/components/alert"; +import { Button, ButtonVariants } from "@/components/button"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { Translated } from "@/components/translated"; +import { UserAvatar } from "@/components/user-avatar"; +import { + getMostRecentCookieWithLoginname, + getSessionCookieById, +} from "@/lib/cookies"; +import { completeDeviceAuthorization } from "@/lib/server/device"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { loadMostRecentSession } from "@/lib/session"; +import { + getBrandingSettings, + getLoginSettings, + getSession, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; +import Link from "next/link"; + +async function loadSessionById( + serviceUrl: string, + sessionId: string, + organization?: string, +) { + const recent = await getSessionCookieById({ sessionId, organization }); + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); +} + +export default async function Page(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { loginName, requestId, organization, sessionId } = searchParams; + + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + // complete device authorization flow if device requestId is present + if (requestId && requestId.startsWith("device_")) { + const cookie = sessionId + ? await getSessionCookieById({ sessionId, organization }) + : await getMostRecentCookieWithLoginname({ + loginName: loginName, + organization: organization, + }); + + await completeDeviceAuthorization(requestId.replace("device_", ""), { + sessionId: cookie.id, + sessionToken: cookie.token, + }).catch((err) => { + return ( + +
+

+ +

+

+ +

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

+ +

+

+ +

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

+ +

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

+ +

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

+ +

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

+ {" "} + +

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

+ +

+

+ +

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

+ +

+

+ +

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

+ +

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

+ +

+

+ +

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

+ +

+

+ +

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

+ +

+

+ +

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

+ +

+

+ +

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

+ +

+

+ +

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

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

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

+ + + +

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

+ + + +

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

+ +

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

+ +

+ {!!identityProviders.length && identityProviders?.map(renderIDPButton)} + {state?.error && ( +
+ {state?.error} +
+ )} +
+ ); +} + +SignInWithIdp.displayName = "SignInWithIDP"; diff --git a/login/apps/login/src/components/skeleton-card.tsx b/login/apps/login/src/components/skeleton-card.tsx new file mode 100644 index 0000000000..80b3793e8f --- /dev/null +++ b/login/apps/login/src/components/skeleton-card.tsx @@ -0,0 +1,16 @@ +import { clsx } from "clsx"; + +export const SkeletonCard = ({ isLoading }: { isLoading?: boolean }) => ( +
+
+
+
+
+
+
+); diff --git a/login/apps/login/src/components/skeleton.tsx b/login/apps/login/src/components/skeleton.tsx new file mode 100644 index 0000000000..548953d278 --- /dev/null +++ b/login/apps/login/src/components/skeleton.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from "react"; + +export function Skeleton({ children }: { children?: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/login/apps/login/src/components/spinner.tsx b/login/apps/login/src/components/spinner.tsx new file mode 100644 index 0000000000..5ed2f04c80 --- /dev/null +++ b/login/apps/login/src/components/spinner.tsx @@ -0,0 +1,22 @@ +import { FC } from "react"; + +export const Spinner: FC<{ className?: string }> = ({ className = "" }) => { + return ( + + + + + ); +}; diff --git a/login/apps/login/src/components/state-badge.tsx b/login/apps/login/src/components/state-badge.tsx new file mode 100644 index 0000000000..00151390bf --- /dev/null +++ b/login/apps/login/src/components/state-badge.tsx @@ -0,0 +1,40 @@ +import { clsx } from "clsx"; +import { ReactNode } from "react"; + +export enum BadgeState { + Info = "info", + Error = "error", + Success = "success", + Alert = "alert", +} + +export type StateBadgeProps = { + state: BadgeState; + children: ReactNode; + evenPadding?: boolean; +}; + +const getBadgeClasses = (state: BadgeState, evenPadding: boolean) => + clsx({ + "w-fit border-box h-18.5px flex flex-row items-center whitespace-nowrap tracking-wider leading-4 items-center justify-center px-2 py-2px text-12px rounded-full shadow-sm": + true, + "bg-state-success-light-background text-state-success-light-color dark:bg-state-success-dark-background dark:text-state-success-dark-color ": + state === BadgeState.Success, + "bg-state-neutral-light-background text-state-neutral-light-color dark:bg-state-neutral-dark-background dark:text-state-neutral-dark-color": + state === BadgeState.Info, + "bg-state-error-light-background text-state-error-light-color dark:bg-state-error-dark-background dark:text-state-error-dark-color": + state === BadgeState.Error, + "bg-state-alert-light-background text-state-alert-light-color dark:bg-state-alert-dark-background dark:text-state-alert-dark-color": + state === BadgeState.Alert, + "p-[2px]": evenPadding, + }); + +export function StateBadge({ + state = BadgeState.Success, + evenPadding = false, + children, +}: StateBadgeProps) { + return ( + {children} + ); +} diff --git a/login/apps/login/src/components/tab-group.tsx b/login/apps/login/src/components/tab-group.tsx new file mode 100644 index 0000000000..afa625d345 --- /dev/null +++ b/login/apps/login/src/components/tab-group.tsx @@ -0,0 +1,16 @@ +import { Tab } from "@/components/tab"; + +export type Item = { + text: string; + slug?: string; +}; + +export const TabGroup = ({ path, items }: { path: string; items: Item[] }) => { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}; diff --git a/login/apps/login/src/components/tab.tsx b/login/apps/login/src/components/tab.tsx new file mode 100644 index 0000000000..bd82931b5a --- /dev/null +++ b/login/apps/login/src/components/tab.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { Item } from "@/components/tab-group"; +import { clsx } from "clsx"; +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; + +export const Tab = ({ + path, + item: { slug, text }, +}: { + path: string; + item: Item; +}) => { + const segment = useSelectedLayoutSegment(); + const href = slug ? path + "/" + slug : path; + const isActive = + // Example home pages e.g. `/layouts` + (!slug && segment === null) || + // Nested pages e.g. `/layouts/electronics` + segment === slug; + + return ( + + {text} + + ); +}; diff --git a/login/apps/login/src/components/theme-provider.tsx b/login/apps/login/src/components/theme-provider.tsx new file mode 100644 index 0000000000..a8a72f86a6 --- /dev/null +++ b/login/apps/login/src/components/theme-provider.tsx @@ -0,0 +1,16 @@ +"use client"; +import { ThemeProvider as ThemeP } from "next-themes"; +import { ReactNode } from "react"; + +export function ThemeProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/login/apps/login/src/components/theme-wrapper.tsx b/login/apps/login/src/components/theme-wrapper.tsx new file mode 100644 index 0000000000..314c3a2ef0 --- /dev/null +++ b/login/apps/login/src/components/theme-wrapper.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { setTheme } from "@/helpers/colors"; +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import { ReactNode, useEffect } from "react"; + +type Props = { + branding: BrandingSettings | undefined; + children: ReactNode; +}; + +export const ThemeWrapper = ({ children, branding }: Props) => { + useEffect(() => { + setTheme(document, branding); + }, [branding]); + + return
{children}
; +}; diff --git a/login/apps/login/src/components/theme.tsx b/login/apps/login/src/components/theme.tsx new file mode 100644 index 0000000000..86d39476ff --- /dev/null +++ b/login/apps/login/src/components/theme.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { MoonIcon, SunIcon } from "@heroicons/react/24/outline"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +export function Theme() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + const isDark = resolvedTheme === "dark"; + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/login/apps/login/src/components/totp-register.tsx b/login/apps/login/src/components/totp-register.tsx new file mode 100644 index 0000000000..ea40fffbf0 --- /dev/null +++ b/login/apps/login/src/components/totp-register.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { getNextUrl } from "@/lib/client"; +import { verifyTOTP } from "@/lib/server/verify"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { QRCodeSVG } from "qrcode.react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { Button, ButtonVariants } from "./button"; +import { CopyToClipboard } from "./copy-to-clipboard"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + code: string; +}; + +type Props = { + uri: string; + secret: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; + checkAfter?: boolean; + loginSettings?: LoginSettings; +}; +export function TotpRegister({ + uri, + secret, + loginName, + sessionId, + requestId, + organization, + checkAfter, + loginSettings, +}: Props) { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: "", + }, + }); + + async function continueWithCode(values: Inputs) { + setLoading(true); + return verifyTOTP(values.code, loginName, organization) + .then(async () => { + // if attribute is set, validate MFA after it is setup, otherwise proceed as usual (when mfa is enforced to login) + if (checkAfter) { + const params = new URLSearchParams({}); + + if (loginName) { + params.append("loginName", loginName); + } + if (requestId) { + params.append("requestId", requestId); + } + if (organization) { + params.append("organization", organization); + } + + return router.push(`/otp/time-based?` + params); + } else { + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + if (url) { + return router.push(url); + } + } + }) + .catch((e) => { + setError(e.message); + return; + }) + .finally(() => { + setLoading(false); + }); + } + + return ( +
+ {uri && ( + <> + +
+ + {uri} + + + +
+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+ + )} +
+ ); +} diff --git a/login/apps/login/src/components/translated.tsx b/login/apps/login/src/components/translated.tsx new file mode 100644 index 0000000000..807ea18e8f --- /dev/null +++ b/login/apps/login/src/components/translated.tsx @@ -0,0 +1,23 @@ +import { useTranslations } from "next-intl"; + +export function Translated({ + i18nKey, + children, + namespace, + data, + ...props +}: { + i18nKey: string; + children?: React.ReactNode; + namespace?: string; + data?: any; +} & React.HTMLAttributes) { + const t = useTranslations(namespace); + const helperKey = `${namespace ? `${namespace}.` : ""}${i18nKey}`; + + return ( + + {t(i18nKey, data)} + + ); +} diff --git a/login/apps/login/src/components/user-avatar.tsx b/login/apps/login/src/components/user-avatar.tsx new file mode 100644 index 0000000000..f2aa0bfed7 --- /dev/null +++ b/login/apps/login/src/components/user-avatar.tsx @@ -0,0 +1,59 @@ +import { Avatar } from "@/components/avatar"; +import { ChevronDownIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; + +type Props = { + loginName?: string; + displayName?: string; + showDropdown: boolean; + searchParams?: Record; +}; + +export function UserAvatar({ + loginName, + displayName, + showDropdown, + searchParams, +}: Props) { + const params = new URLSearchParams({}); + + if (searchParams?.sessionId) { + params.set("sessionId", searchParams.sessionId); + } + + if (searchParams?.organization) { + params.set("organization", searchParams.organization); + } + + if (searchParams?.requestId) { + params.set("requestId", searchParams.requestId); + } + + if (searchParams?.loginName) { + params.set("loginName", searchParams.loginName); + } + + return ( +
+
+ +
+ + {loginName} + + + {showDropdown && ( + + + + )} +
+ ); +} diff --git a/login/apps/login/src/components/username-form.tsx b/login/apps/login/src/components/username-form.tsx new file mode 100644 index 0000000000..1dffade4b5 --- /dev/null +++ b/login/apps/login/src/components/username-form.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { sendLoginname } from "@/lib/server/loginname"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { useRouter } from "next/navigation"; +import { ReactNode, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { Alert } from "./alert"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + loginName: string; +}; + +type Props = { + loginName: string | undefined; + requestId: string | undefined; + loginSettings: LoginSettings | undefined; + organization?: string; + suffix?: string; + submit: boolean; + allowRegister: boolean; + children?: ReactNode; +}; + +export function UsernameForm({ + loginName, + requestId, + organization, + suffix, + loginSettings, + submit, + allowRegister, + children, +}: Props) { + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + loginName: loginName ? loginName : "", + }, + }); + + const router = useRouter(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + async function submitLoginName(values: Inputs, organization?: string) { + setLoading(true); + + const res = await sendLoginname({ + loginName: values.loginName, + organization, + requestId, + suffix, + }) + .catch(() => { + setError("An internal error occurred"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (res && "redirect" in res && res.redirect) { + return router.push(res.redirect); + } + + if (res && "error" in res && res.error) { + setError(res.error); + return; + } + + return res; + } + + useEffect(() => { + if (submit && loginName) { + // When we navigate to this page, we always want to be redirected if submit is true and the parameters are valid. + submitLoginName({ loginName }, organization); + } + }, []); + + let inputLabel = "Loginname"; + if ( + loginSettings?.disableLoginWithEmail && + loginSettings?.disableLoginWithPhone + ) { + inputLabel = "Username"; + } else if (loginSettings?.disableLoginWithEmail) { + inputLabel = "Username or phone number"; + } else if (loginSettings?.disableLoginWithPhone) { + inputLabel = "Username or email"; + } + + return ( +
+
+ + {allowRegister && ( + + )} +
+ + {error && ( +
+ {error} +
+ )} +
+ + + +
+
+ ); +} diff --git a/login/apps/login/src/components/verify-form.tsx b/login/apps/login/src/components/verify-form.tsx new file mode 100644 index 0000000000..dac4c91314 --- /dev/null +++ b/login/apps/login/src/components/verify-form.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Alert, AlertType } from "@/components/alert"; +import { resendVerification, sendVerification } from "@/lib/server/verify"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; +import { Translated } from "./translated"; + +type Inputs = { + code: string; +}; + +type Props = { + userId: string; + loginName?: string; + organization?: string; + code?: string; + isInvite: boolean; + requestId?: string; +}; + +export function VerifyForm({ + userId, + loginName, + organization, + requestId, + code, + isInvite, +}: Props) { + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + code: code ?? "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function resendCode() { + setError(""); + setLoading(true); + + const response = await resendVerification({ + userId, + isInvite: isInvite, + }) + .catch(() => { + setError("Could not resend email"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + + return response; + } + + const fcn = useCallback( + async function submitCodeAndContinue( + value: Inputs, + ): Promise { + setLoading(true); + + const response = await sendVerification({ + code: value.code, + userId, + isInvite: isInvite, + loginName: loginName, + organization: organization, + requestId: requestId, + }) + .catch(() => { + setError("Could not verify user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response?.redirect) { + return router.push(response?.redirect); + } + }, + [isInvite, userId], + ); + + useEffect(() => { + if (code) { + fcn({ code }); + } + }, [code, fcn]); + + return ( + <> +
+ +
+ + + + +
+
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/login/apps/login/src/components/zitadel-logo-dark.tsx b/login/apps/login/src/components/zitadel-logo-dark.tsx new file mode 100644 index 0000000000..0df6ae2004 --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo-dark.tsx @@ -0,0 +1,210 @@ +import { FC } from "react"; + +export const ZitadelLogoDark: FC = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/login/apps/login/src/components/zitadel-logo-light.tsx b/login/apps/login/src/components/zitadel-logo-light.tsx new file mode 100644 index 0000000000..51529aa821 --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo-light.tsx @@ -0,0 +1,210 @@ +import { FC } from "react"; + +export const ZitadelLogoLight: FC = (props) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/login/apps/login/src/components/zitadel-logo.tsx b/login/apps/login/src/components/zitadel-logo.tsx new file mode 100644 index 0000000000..105665fbba --- /dev/null +++ b/login/apps/login/src/components/zitadel-logo.tsx @@ -0,0 +1,32 @@ +import Image from "next/image"; +type Props = { + height?: number; + width?: number; +}; + +export function ZitadelLogo({ height = 40, width = 147.5 }: Props) { + return ( + <> +
+ {/* */} + + zitadel logo +
+
+ zitadel logo +
+ + ); +} diff --git a/login/apps/login/src/helpers/base64.ts b/login/apps/login/src/helpers/base64.ts new file mode 100644 index 0000000000..967cdc8d17 --- /dev/null +++ b/login/apps/login/src/helpers/base64.ts @@ -0,0 +1,63 @@ +export function coerceToBase64Url(thing: any, name: string) { + // Array or ArrayBuffer to Uint8Array + if (Array.isArray(thing)) { + thing = Uint8Array.from(thing); + } + + if (thing instanceof ArrayBuffer) { + thing = new Uint8Array(thing); + } + + // Uint8Array to base64 + if (thing instanceof Uint8Array) { + var str = ""; + var len = thing.byteLength; + + for (var i = 0; i < len; i++) { + str += String.fromCharCode(thing[i]); + } + thing = window.btoa(str); + } + + if (typeof thing !== "string") { + throw new Error("could not coerce '" + name + "' to string"); + } + + // base64 to base64url + // NOTE: "=" at the end of challenge is optional, strip it off here + thing = thing.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + + return thing; +} + +export function coerceToArrayBuffer(thing: any, name: string) { + if (typeof thing === "string") { + // base64url to base64 + thing = thing.replace(/-/g, "+").replace(/_/g, "/"); + + // base64 to Uint8Array + var str = window.atob(thing); + var bytes = new Uint8Array(str.length); + for (var i = 0; i < str.length; i++) { + bytes[i] = str.charCodeAt(i); + } + thing = bytes; + } + + // Array to Uint8Array + if (Array.isArray(thing)) { + thing = new Uint8Array(thing); + } + + // Uint8Array to ArrayBuffer + if (thing instanceof Uint8Array) { + thing = thing.buffer; + } + + // error if none of the above worked + if (!(thing instanceof ArrayBuffer)) { + throw new TypeError("could not coerce '" + name + "' to ArrayBuffer"); + } + + return thing; +} diff --git a/login/apps/login/src/helpers/colors.ts b/login/apps/login/src/helpers/colors.ts new file mode 100644 index 0000000000..bdb07cecfd --- /dev/null +++ b/login/apps/login/src/helpers/colors.ts @@ -0,0 +1,439 @@ +import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; +import tinycolor from "tinycolor2"; + +export interface Color { + name: string; + hex: string; + rgb: string; + contrastColor: string; +} + +export type MapName = "background" | "primary" | "warn" | "text" | "link"; + +export type ColorName = + | "50" + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "C900" + | "A100" + | "A200" + | "A400" + | "A700"; + +export type ColorMap = { + [key in MapName]: Color[]; +}; + +export const DARK_PRIMARY = "#2073c4"; +export const PRIMARY = "#5469d4"; + +export const DARK_WARN = "#ff3b5b"; +export const WARN = "#cd3d56"; + +export const DARK_BACKGROUND = "#111827"; +export const BACKGROUND = "#fafafa"; + +export const DARK_TEXT = "#ffffff"; +export const TEXT = "#000000"; + +export type LabelPolicyColors = { + backgroundColor: string; + backgroundColorDark: string; + fontColor: string; + fontColorDark: string; + warnColor: string; + warnColorDark: string; + primaryColor: string; + primaryColorDark: string; +}; + +type BrandingColors = { + lightTheme: { + backgroundColor: string; + fontColor: string; + primaryColor: string; + warnColor: string; + }; + darkTheme: { + backgroundColor: string; + fontColor: string; + primaryColor: string; + warnColor: string; + }; +}; + +export function setTheme(document: any, policy?: BrandingSettings) { + const lP: BrandingColors = { + lightTheme: { + backgroundColor: policy?.lightTheme?.backgroundColor || BACKGROUND, + fontColor: policy?.lightTheme?.fontColor || TEXT, + primaryColor: policy?.lightTheme?.primaryColor || PRIMARY, + warnColor: policy?.lightTheme?.warnColor || WARN, + }, + darkTheme: { + backgroundColor: policy?.darkTheme?.backgroundColor || DARK_BACKGROUND, + fontColor: policy?.darkTheme?.fontColor || DARK_TEXT, + primaryColor: policy?.darkTheme?.primaryColor || DARK_PRIMARY, + warnColor: policy?.darkTheme?.warnColor || DARK_WARN, + }, + }; + + const dark = computeMap(lP, true); + const light = computeMap(lP, false); + + setColorShades(dark.background, "background", "dark", document); + setColorShades(light.background, "background", "light", document); + + setColorShades(dark.primary, "primary", "dark", document); + setColorShades(light.primary, "primary", "light", document); + + setColorShades(dark.warn, "warn", "dark", document); + setColorShades(light.warn, "warn", "light", document); + + setColorAlpha(dark.text, "text", "dark", document); + setColorAlpha(light.text, "text", "light", document); + + setColorAlpha(dark.link, "link", "dark", document); + setColorAlpha(light.link, "link", "light", document); +} + +function setColorShades( + map: Color[], + type: string, + theme: string, + document: any, +) { + map.forEach((color) => { + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-${color.name}`, + color.hex, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-contrast-${color.name}`, + color.contrastColor, + ); + }); +} + +function setColorAlpha( + map: Color[], + type: string, + theme: string, + document: any, +) { + map.forEach((color) => { + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-${color.name}`, + color.hex, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-contrast-${color.name}`, + color.contrastColor, + ); + document.documentElement.style.setProperty( + `--theme-${theme}-${type}-secondary-${color.name}`, + `${color.hex}c7`, + ); + }); +} + +function computeColors(hex: string): Color[] { + return [ + getColorObject(tinycolor(hex).lighten(52), "50"), + getColorObject(tinycolor(hex).lighten(37), "100"), + getColorObject(tinycolor(hex).lighten(26), "200"), + getColorObject(tinycolor(hex).lighten(12), "300"), + getColorObject(tinycolor(hex).lighten(6), "400"), + getColorObject(tinycolor(hex), "500"), + getColorObject(tinycolor(hex).darken(6), "600"), + getColorObject(tinycolor(hex).darken(12), "700"), + getColorObject(tinycolor(hex).darken(18), "800"), + getColorObject(tinycolor(hex).darken(24), "900"), + getColorObject(tinycolor(hex).lighten(50).saturate(30), "A100"), + getColorObject(tinycolor(hex).lighten(30).saturate(30), "A200"), + getColorObject(tinycolor(hex).lighten(10).saturate(15), "A400"), + getColorObject(tinycolor(hex).lighten(5).saturate(5), "A700"), + ]; +} + +function getColorObject(value: any, name: string): Color { + const c = tinycolor(value); + return { + name: name, + hex: c.toHexString(), + rgb: c.toRgbString(), + contrastColor: getContrast(c.toHexString()), + } as Color; +} + +function getContrast(color: string): string { + const onBlack = tinycolor.readability("#000", color); + const onWhite = tinycolor.readability("#fff", color); + if (onBlack > onWhite) { + return "hsla(0, 0%, 0%, 0.87)"; + } else { + return "#ffffff"; + } +} + +export function computeMap(branding: BrandingColors, dark: boolean): ColorMap { + return { + background: computeColors( + dark + ? branding.darkTheme.backgroundColor + : branding.lightTheme.backgroundColor, + ), + primary: computeColors( + dark ? branding.darkTheme.primaryColor : branding.lightTheme.primaryColor, + ), + warn: computeColors( + dark ? branding.darkTheme.warnColor : branding.lightTheme.warnColor, + ), + text: computeColors( + dark ? branding.darkTheme.fontColor : branding.lightTheme.fontColor, + ), + link: computeColors( + dark ? branding.darkTheme.fontColor : branding.lightTheme.fontColor, + ), + }; +} + +export interface ColorShade { + 200: string; + 300: string; + 500: string; + 600: string; + 700: string; + 900: string; +} + +export const COLORS = [ + { + 500: "#ef4444", + 200: "#fecaca", + 300: "#fca5a5", + 600: "#dc2626", + 700: "#b91c1c", + 900: "#7f1d1d", + }, + { + 500: "#f97316", + 200: "#fed7aa", + 300: "#fdba74", + 600: "#ea580c", + 700: "#c2410c", + 900: "#7c2d12", + }, + { + 500: "#f59e0b", + 200: "#fde68a", + 300: "#fcd34d", + 600: "#d97706", + 700: "#b45309", + 900: "#78350f", + }, + { + 500: "#eab308", + 200: "#fef08a", + 300: "#fde047", + 600: "#ca8a04", + 700: "#a16207", + 900: "#713f12", + }, + { + 500: "#84cc16", + 200: "#d9f99d", + 300: "#bef264", + 600: "#65a30d", + 700: "#4d7c0f", + 900: "#365314", + }, + { + 500: "#22c55e", + 200: "#bbf7d0", + 300: "#86efac", + 600: "#16a34a", + 700: "#15803d", + 900: "#14532d", + }, + { + 500: "#10b981", + 200: "#a7f3d0", + 300: "#6ee7b7", + 600: "#059669", + 700: "#047857", + 900: "#064e3b", + }, + { + 500: "#14b8a6", + 200: "#99f6e4", + 300: "#5eead4", + 600: "#0d9488", + 700: "#0f766e", + 900: "#134e4a", + }, + { + 500: "#06b6d4", + 200: "#a5f3fc", + 300: "#67e8f9", + 600: "#0891b2", + 700: "#0e7490", + 900: "#164e63", + }, + { + 500: "#0ea5e9", + 200: "#bae6fd", + 300: "#7dd3fc", + 600: "#0284c7", + 700: "#0369a1", + 900: "#0c4a6e", + }, + { + 500: "#3b82f6", + 200: "#bfdbfe", + 300: "#93c5fd", + 600: "#2563eb", + 700: "#1d4ed8", + 900: "#1e3a8a", + }, + { + 500: "#6366f1", + 200: "#c7d2fe", + 300: "#a5b4fc", + 600: "#4f46e5", + 700: "#4338ca", + 900: "#312e81", + }, + { + 500: "#8b5cf6", + 200: "#ddd6fe", + 300: "#c4b5fd", + 600: "#7c3aed", + 700: "#6d28d9", + 900: "#4c1d95", + }, + { + 500: "#a855f7", + 200: "#e9d5ff", + 300: "#d8b4fe", + 600: "#9333ea", + 700: "#7e22ce", + 900: "#581c87", + }, + { + 500: "#d946ef", + 200: "#f5d0fe", + 300: "#f0abfc", + 600: "#c026d3", + 700: "#a21caf", + 900: "#701a75", + }, + { + 500: "#ec4899", + 200: "#fbcfe8", + 300: "#f9a8d4", + 600: "#db2777", + 700: "#be185d", + 900: "#831843", + }, + { + 500: "#f43f5e", + 200: "#fecdd3", + 300: "#fda4af", + 600: "#e11d48", + 700: "#be123c", + 900: "#881337", + }, +]; + +export function getColorHash(value: string): ColorShade { + let hash = 0; + + if (value.length === 0) { + return COLORS[hash]; + } + + hash = hashCode(value); + return COLORS[hash % COLORS.length]; +} + +export function hashCode(str: string, seed = 0): number { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +} + +export function getMembershipColor(role: string): ColorShade { + const hash = hashCode(role); + let color = COLORS[hash % COLORS.length]; + + switch (role) { + case "IAM_OWNER": + color = COLORS[0]; + break; + case "IAM_OWNER_VIEWER": + color = COLORS[14]; + break; + case "IAM_ORG_MANAGER": + color = COLORS[11]; + break; + case "IAM_USER_MANAGER": + color = COLORS[8]; + break; + + case "ORG_OWNER": + color = COLORS[16]; + break; + case "ORG_USER_MANAGER": + color = COLORS[8]; + break; + case "ORG_OWNER_VIEWER": + color = COLORS[14]; + break; + case "ORG_USER_PERMISSION_EDITOR": + color = COLORS[7]; + break; + case "ORG_PROJECT_PERMISSION_EDITOR": + color = COLORS[11]; + break; + case "ORG_PROJECT_CREATOR": + color = COLORS[12]; + break; + + case "PROJECT_OWNER": + color = COLORS[9]; + break; + case "PROJECT_OWNER_VIEWER": + color = COLORS[10]; + break; + case "PROJECT_OWNER_GLOBAL": + color = COLORS[11]; + break; + case "PROJECT_OWNER_VIEWER_GLOBAL": + color = COLORS[12]; + break; + + default: + color = COLORS[hash % COLORS.length]; + break; + } + + return color; +} diff --git a/login/apps/login/src/helpers/validators.ts b/login/apps/login/src/helpers/validators.ts new file mode 100644 index 0000000000..6a61d13ece --- /dev/null +++ b/login/apps/login/src/helpers/validators.ts @@ -0,0 +1,19 @@ +export function symbolValidator(value: string): boolean { + const REGEXP = /[^a-zA-Z0-9]/gi; + return REGEXP.test(value); +} + +export function numberValidator(value: string): boolean { + const REGEXP = /[0-9]/g; + return REGEXP.test(value); +} + +export function upperCaseValidator(value: string): boolean { + const REGEXP = /[A-Z]/g; + return REGEXP.test(value); +} + +export function lowerCaseValidator(value: string): boolean { + const REGEXP = /[a-z]/g; + return REGEXP.test(value); +} diff --git a/login/apps/login/src/i18n/request.ts b/login/apps/login/src/i18n/request.ts new file mode 100644 index 0000000000..9e5e37e231 --- /dev/null +++ b/login/apps/login/src/i18n/request.ts @@ -0,0 +1,59 @@ +import { LANGS, LANGUAGE_COOKIE_NAME, LANGUAGE_HEADER_NAME } from "@/lib/i18n"; +import { getServiceUrlFromHeaders } from "@/lib/service-url"; +import { getHostedLoginTranslation } from "@/lib/zitadel"; +import { JsonObject } from "@zitadel/client"; +import deepmerge from "deepmerge"; +import { getRequestConfig } from "next-intl/server"; +import { cookies, headers } from "next/headers"; + +export default getRequestConfig(async () => { + const fallback = "en"; + const cookiesList = await cookies(); + + let locale: string = fallback; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const i18nOrganization = _headers.get("x-zitadel-i18n-organization") || ""; // You may need to set this header in middleware + + let translations: JsonObject | {} = {}; + try { + const i18nJSON = await getHostedLoginTranslation({ + serviceUrl, + locale, + organization: i18nOrganization, + }); + + if (i18nJSON) { + translations = i18nJSON; + } + } catch (error) { + console.warn("Error fetching custom translations:", error); + } + + const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); + if (languageHeader) { + const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code + if (LANGS.map((l) => l.code).includes(headerLocale)) { + locale = headerLocale; + } + } + + const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME); + if (languageCookie && languageCookie.value) { + if (LANGS.map((l) => l.code).includes(languageCookie.value)) { + locale = languageCookie.value; + } + } + + const customMessages = translations; + const localeMessages = (await import(`../../locales/${locale}.json`)).default; + const fallbackMessages = (await import(`../../locales/${fallback}.json`)) + .default; + + return { + locale, + messages: deepmerge.all([fallbackMessages, localeMessages, customMessages]), + }; +}); diff --git a/login/apps/login/src/lib/api.ts b/login/apps/login/src/lib/api.ts new file mode 100644 index 0000000000..7324007307 --- /dev/null +++ b/login/apps/login/src/lib/api.ts @@ -0,0 +1,17 @@ +import { newSystemToken } from "@zitadel/client/node"; + +export async function systemAPIToken() { + const token = { + audience: process.env.AUDIENCE, + userID: process.env.SYSTEM_USER_ID, + token: Buffer.from(process.env.SYSTEM_USER_PRIVATE_KEY, "base64").toString( + "utf-8", + ), + }; + + return newSystemToken({ + audience: token.audience, + subject: token.userID, + key: token.token, + }); +} diff --git a/login/apps/login/src/lib/client.ts b/login/apps/login/src/lib/client.ts new file mode 100644 index 0000000000..a59af90b77 --- /dev/null +++ b/login/apps/login/src/lib/client.ts @@ -0,0 +1,80 @@ +type FinishFlowCommand = + | { + sessionId: string; + requestId: string; + } + | { loginName: string }; + +function goToSignedInPage( + props: + | { sessionId: string; organization?: string; requestId?: string } + | { organization?: string; loginName: string; requestId?: string }, +) { + const params = new URLSearchParams({}); + + if ("loginName" in props && props.loginName) { + params.append("loginName", props.loginName); + } + + if ("sessionId" in props && props.sessionId) { + params.append("sessionId", props.sessionId); + } + + if (props.organization) { + params.append("organization", props.organization); + } + + // required to show conditional UI for device flow + if (props.requestId) { + params.append("requestId", props.requestId); + } + + return `/signedin?` + params; +} + +/** + * for client: redirects user back to an OIDC or SAML application or to a success page when using requestId, check if a default redirect and redirect to it, or just redirect to a success page with the loginName + * @param command + * @returns + */ +export async function getNextUrl( + command: FinishFlowCommand & { organization?: string }, + defaultRedirectUri?: string, +): Promise { + // finish Device Authorization Flow + if ( + "requestId" in command && + command.requestId.startsWith("device_") && + ("loginName" in command || "sessionId" in command) + ) { + return goToSignedInPage({ + ...command, + organization: command.organization, + }); + } + + // finish SAML or OIDC flow + if ( + "sessionId" in command && + "requestId" in command && + (command.requestId.startsWith("saml_") || + command.requestId.startsWith("oidc_")) + ) { + const params = new URLSearchParams({ + sessionId: command.sessionId, + requestId: command.requestId, + }); + + if (command.organization) { + params.append("organization", command.organization); + } + + return `/login?` + params; + } + + if (defaultRedirectUri) { + return defaultRedirectUri; + } + + return goToSignedInPage(command); +} diff --git a/login/apps/login/src/lib/cookies.ts b/login/apps/login/src/lib/cookies.ts new file mode 100644 index 0000000000..7de87a98e7 --- /dev/null +++ b/login/apps/login/src/lib/cookies.ts @@ -0,0 +1,341 @@ +"use server"; + +import { timestampDate, timestampFromMs } from "@zitadel/client"; +import { cookies } from "next/headers"; +import { LANGUAGE_COOKIE_NAME } from "./i18n"; + +// TODO: improve this to handle overflow +const MAX_COOKIE_SIZE = 2048; + +export type Cookie = { + id: string; + token: string; + loginName: string; + organization?: string; + creationTs: string; + expirationTs: string; + changeTs: string; + requestId?: string; // if its linked to an OIDC flow +}; + +type SessionCookie = Cookie & T; + +async function setSessionHttpOnlyCookie( + sessions: SessionCookie[], + sameSite: boolean | "lax" | "strict" | "none" = true, +) { + const cookiesList = await cookies(); + + return cookiesList.set({ + name: "sessions", + value: JSON.stringify(sessions), + httpOnly: true, + path: "/", + sameSite: process.env.NODE_ENV === "production" ? sameSite : "lax", + secure: process.env.NODE_ENV === "production", + }); +} + +export async function setLanguageCookie(language: string) { + const cookiesList = await cookies(); + + await cookiesList.set({ + name: LANGUAGE_COOKIE_NAME, + value: language, + httpOnly: true, + path: "/", + }); +} + +export async function addSessionToCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + let currentSessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : []; + + const index = currentSessions.findIndex( + (s) => s.loginName === session.loginName, + ); + + if (index > -1) { + currentSessions[index] = session; + } else { + const temp = [...currentSessions, session]; + + if (JSON.stringify(temp).length >= MAX_COOKIE_SIZE) { + console.log("WARNING COOKIE OVERFLOW"); + // TODO: improve cookie handling + // this replaces the first session (oldest) with the new one + currentSessions = [session].concat(currentSessions.slice(1)); + } else { + currentSessions = [session].concat(currentSessions); + } + } + + if (cleanup) { + const now = new Date(); + const filteredSessions = currentSessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(currentSessions, sameSite); + } +} + +export async function updateSessionCookie({ + id, + session, + cleanup, + sameSite, +}: { + id: string; + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + const sessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : [session]; + + const foundIndex = sessions.findIndex((session) => session.id === id); + + if (foundIndex > -1) { + sessions[foundIndex] = session; + if (cleanup) { + const now = new Date(); + const filteredSessions = sessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(sessions, sameSite); + } + } else { + throw "updateSessionCookie: session id now found"; + } +} + +export async function removeSessionFromCookie({ + session, + cleanup, + sameSite, +}: { + session: SessionCookie; + cleanup?: boolean; + sameSite?: boolean | "lax" | "strict" | "none" | undefined; +}) { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + const sessions: SessionCookie[] = stringifiedCookie?.value + ? JSON.parse(stringifiedCookie?.value) + : [session]; + + const reducedSessions = sessions.filter((s) => s.id !== session.id); + if (cleanup) { + const now = new Date(); + const filteredSessions = reducedSessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + return setSessionHttpOnlyCookie(filteredSessions, sameSite); + } else { + return setSessionHttpOnlyCookie(reducedSessions, sameSite); + } +} + +export async function getMostRecentSessionCookie(): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + const latest = sessions.reduce((prev, current) => { + return prev.changeTs > current.changeTs ? prev : current; + }); + + return latest; + } else { + return Promise.reject("no session cookie found"); + } +} + +export async function getSessionCookieById({ + sessionId, + organization, +}: { + sessionId: string; + organization?: string; +}): Promise> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + const found = sessions.find((s) => + organization + ? s.organization === organization && s.id === sessionId + : s.id === sessionId, + ); + if (found) { + return found; + } else { + return Promise.reject(); + } + } else { + return Promise.reject(); + } +} + +export async function getSessionCookieByLoginName({ + loginName, + organization, +}: { + loginName?: string; + organization?: string; +}): Promise> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + const found = sessions.find((s) => + organization + ? s.organization === organization && s.loginName === loginName + : s.loginName === loginName, + ); + if (found) { + return found; + } else { + return Promise.reject("no cookie found with loginName: " + loginName); + } + } else { + return Promise.reject("no session cookie found"); + } +} + +/** + * + * @param cleanup when true, removes all expired sessions, default true + * @returns Session Cookies + */ +export async function getAllSessionCookieIds( + cleanup: boolean = false, +): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + if (cleanup) { + const now = new Date(); + return sessions + .filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ) + .map((session) => session.id); + } else { + return sessions.map((session) => session.id); + } + } else { + return []; + } +} + +/** + * + * @param cleanup when true, removes all expired sessions, default true + * @returns Session Cookies + */ +export async function getAllSessions( + cleanup: boolean = false, +): Promise[]> { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + + if (cleanup) { + const now = new Date(); + return sessions.filter((session) => + session.expirationTs + ? timestampDate(timestampFromMs(Number(session.expirationTs))) > now + : true, + ); + } else { + return sessions; + } + } else { + return []; + } +} + +/** + * Returns most recent session filtered by optinal loginName + * @param loginName optional loginName to filter cookies, if non provided, returns most recent session + * @param organization optional organization to filter cookies + * @returns most recent session + */ +export async function getMostRecentCookieWithLoginname({ + loginName, + organization, +}: { + loginName?: string; + organization?: string; +}): Promise { + const cookiesList = await cookies(); + const stringifiedCookie = cookiesList.get("sessions"); + + if (stringifiedCookie?.value) { + const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value); + let filtered = sessions.filter((cookie) => { + return !!loginName ? cookie.loginName === loginName : true; + }); + + if (organization) { + filtered = filtered.filter((cookie) => { + return cookie.organization === organization; + }); + } + + const latest = + filtered && filtered.length + ? filtered.reduce((prev, current) => { + return prev.changeTs > current.changeTs ? prev : current; + }) + : undefined; + + if (latest) { + return latest; + } else { + return Promise.reject("Could not get the context or retrieve a session"); + } + } else { + return Promise.reject("Could not read session cookie"); + } +} diff --git a/login/apps/login/src/lib/demos.ts b/login/apps/login/src/lib/demos.ts new file mode 100644 index 0000000000..38912e50e5 --- /dev/null +++ b/login/apps/login/src/lib/demos.ts @@ -0,0 +1,38 @@ +export type Item = { + name: string; + slug: string; + description?: string; +}; + +export const demos: { name: string; items: Item[] }[] = [ + { + name: "Login", + items: [ + { + name: "Loginname", + slug: "loginname", + description: "Start the loginflow with loginname", + }, + { + name: "Accounts", + slug: "accounts", + description: "List active and inactive sessions", + }, + ], + }, + { + name: "Register", + items: [ + { + name: "Register", + slug: "register", + description: "Add a user with password or passkey", + }, + { + name: "IDP Register", + slug: "idp", + description: "Add a user from an external identity provider", + }, + ], + }, +]; diff --git a/login/apps/login/src/lib/fingerprint.ts b/login/apps/login/src/lib/fingerprint.ts new file mode 100644 index 0000000000..55b59dadc8 --- /dev/null +++ b/login/apps/login/src/lib/fingerprint.ts @@ -0,0 +1,66 @@ +import { create } from "@zitadel/client"; +import { + UserAgent, + UserAgentSchema, +} from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { cookies, headers } from "next/headers"; +import { userAgent } from "next/server"; +import { v4 as uuidv4 } from "uuid"; + +export async function getFingerprintId() { + return uuidv4(); +} + +export async function setFingerprintIdCookie(fingerprintId: string) { + const cookiesList = await cookies(); + + return cookiesList.set({ + name: "fingerprintId", + value: fingerprintId, + httpOnly: true, + path: "/", + maxAge: 31536000, // 1 year + }); +} + +export async function getFingerprintIdCookie() { + const cookiesList = await cookies(); + return cookiesList.get("fingerprintId"); +} + +export async function getOrSetFingerprintId(): Promise { + const cookie = await getFingerprintIdCookie(); + if (cookie) { + return cookie.value; + } + + const fingerprintId = await getFingerprintId(); + await setFingerprintIdCookie(fingerprintId); + return fingerprintId; +} + +export async function getUserAgent(): Promise { + const _headers = await headers(); + + const fingerprintId = await getOrSetFingerprintId(); + + const { device, engine, os, browser } = userAgent({ headers: _headers }); + + const userAgentHeader = _headers.get("user-agent"); + + const userAgentHeaderValues = userAgentHeader?.split(","); + + const deviceDescription = `${device?.type ? `${device.type},` : ""} ${device?.vendor ? `${device.vendor},` : ""} ${device.model ? `${device.model},` : ""} `; + const osDescription = `${os?.name ? `${os.name},` : ""} ${os?.version ? `${os.version},` : ""} `; + const engineDescription = `${engine?.name ? `${engine.name},` : ""} ${engine?.version ? `${engine.version},` : ""} `; + const browserDescription = `${browser?.name ? `${browser.name},` : ""} ${browser.version ? `${browser.version},` : ""} `; + + const userAgentData: UserAgent = create(UserAgentSchema, { + ip: _headers.get("x-forwarded-for") ?? _headers.get("remoteAddress") ?? "", + header: { "user-agent": { values: userAgentHeaderValues } }, + description: `${browserDescription}, ${deviceDescription}, ${engineDescription}, ${osDescription}`, + fingerprintId: fingerprintId, + }); + + return userAgentData; +} diff --git a/login/apps/login/src/lib/hooks.ts b/login/apps/login/src/lib/hooks.ts new file mode 100644 index 0000000000..2d43fe5adc --- /dev/null +++ b/login/apps/login/src/lib/hooks.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; + +// Custom hook to read auth record and user profile doc +export function useUserData() { + const [clientData, setClientData] = useState(null); + + useEffect(() => { + let unsubscribe; + + return unsubscribe; + }, [clientData]); + + return { clientData }; +} diff --git a/login/apps/login/src/lib/i18n.ts b/login/apps/login/src/lib/i18n.ts new file mode 100644 index 0000000000..5a101dcc8f --- /dev/null +++ b/login/apps/login/src/lib/i18n.ts @@ -0,0 +1,38 @@ +export interface Lang { + name: string; + code: string; +} + +export const LANGS: Lang[] = [ + { + name: "English", + code: "en", + }, + { + name: "Deutsch", + code: "de", + }, + { + name: "Italiano", + code: "it", + }, + { + name: "Español", + code: "es", + }, + { + name: "Polski", + code: "pl", + }, + { + name: "简体中文", + code: "zh", + }, + { + name: "Русский", + code: "ru", + }, +]; + +export const LANGUAGE_COOKIE_NAME = "NEXT_LOCALE"; +export const LANGUAGE_HEADER_NAME = "accept-language"; diff --git a/login/apps/login/src/lib/idp.ts b/login/apps/login/src/lib/idp.ts new file mode 100644 index 0000000000..d355f9ab56 --- /dev/null +++ b/login/apps/login/src/lib/idp.ts @@ -0,0 +1,77 @@ +import { IDPType } from "@zitadel/proto/zitadel/idp/v2/idp_pb"; +import { IdentityProviderType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; + +// This maps the IdentityProviderType to a slug which is used in the /success and /failure routes +export function idpTypeToSlug(idpType: IdentityProviderType) { + switch (idpType) { + case IdentityProviderType.GITHUB: + return "github"; + case IdentityProviderType.GITHUB_ES: + return "github_es"; + case IdentityProviderType.GITLAB: + return "gitlab"; + case IdentityProviderType.GITLAB_SELF_HOSTED: + return "gitlab_es"; + case IdentityProviderType.APPLE: + return "apple"; + case IdentityProviderType.GOOGLE: + return "google"; + case IdentityProviderType.AZURE_AD: + return "azure"; + case IdentityProviderType.SAML: + return "saml"; + case IdentityProviderType.OAUTH: + return "oauth"; + case IdentityProviderType.OIDC: + return "oidc"; + case IdentityProviderType.LDAP: + return "ldap"; + case IdentityProviderType.JWT: + return "jwt"; + default: + throw new Error("Unknown identity provider type"); + } +} + +// TODO: this is ugly but needed atm as the getIDPByID returns a IDPType and not a IdentityProviderType +export function idpTypeToIdentityProviderType( + idpType: IDPType, +): IdentityProviderType { + switch (idpType) { + case IDPType.IDP_TYPE_GITHUB: + return IdentityProviderType.GITHUB; + + case IDPType.IDP_TYPE_GITHUB_ES: + return IdentityProviderType.GITHUB_ES; + + case IDPType.IDP_TYPE_GITLAB: + return IdentityProviderType.GITLAB; + + case IDPType.IDP_TYPE_GITLAB_SELF_HOSTED: + return IdentityProviderType.GITLAB_SELF_HOSTED; + + case IDPType.IDP_TYPE_APPLE: + return IdentityProviderType.APPLE; + + case IDPType.IDP_TYPE_GOOGLE: + return IdentityProviderType.GOOGLE; + + case IDPType.IDP_TYPE_AZURE_AD: + return IdentityProviderType.AZURE_AD; + + case IDPType.IDP_TYPE_SAML: + return IdentityProviderType.SAML; + + case IDPType.IDP_TYPE_OAUTH: + return IdentityProviderType.OAUTH; + + case IDPType.IDP_TYPE_OIDC: + return IdentityProviderType.OIDC; + + case IDPType.IDP_TYPE_JWT: + return IdentityProviderType.JWT; + + default: + throw new Error("Unknown identity provider type"); + } +} diff --git a/login/apps/login/src/lib/oidc.ts b/login/apps/login/src/lib/oidc.ts new file mode 100644 index 0000000000..b692300dea --- /dev/null +++ b/login/apps/login/src/lib/oidc.ts @@ -0,0 +1,132 @@ +import { Cookie } from "@/lib/cookies"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { createCallback, getLoginSettings } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { + CreateCallbackRequestSchema, + SessionSchema, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { NextRequest, NextResponse } from "next/server"; +import { constructUrl } from "./service-url"; +import { isSessionValid } from "./session"; + +type LoginWithOIDCAndSession = { + serviceUrl: string; + authRequest: string; + sessionId: string; + sessions: Session[]; + sessionCookies: Cookie[]; + request: NextRequest; +}; +export async function loginWithOIDCAndSession({ + serviceUrl, + authRequest, + sessionId, + sessions, + sessionCookies, + request, +}: LoginWithOIDCAndSession) { + console.log( + `Login with session: ${sessionId} and authRequest: ${authRequest}`, + ); + + const selectedSession = sessions.find((s) => s.id === sessionId); + + if (selectedSession && selectedSession.id) { + console.log(`Found session ${selectedSession.id}`); + + const isValid = await isSessionValid({ + serviceUrl, + session: selectedSession, + }); + + console.log("Session is valid:", isValid); + + if (!isValid && selectedSession.factors?.user) { + // if the session is not valid anymore, we need to redirect the user to re-authenticate / + // TODO: handle IDP intent direcly if available + const command: SendLoginnameCommand = { + loginName: selectedSession.factors.user?.loginName, + organization: selectedSession.factors?.user?.organizationId, + requestId: `oidc_${authRequest}`, + }; + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const { callbackUrl } = await createCallback({ + serviceUrl, + req: create(CreateCallbackRequestSchema, { + authRequestId: authRequest, + callbackKind: { + case: "session", + value: create(SessionSchema, session), + }, + }), + }); + if (callbackUrl) { + return NextResponse.redirect(callbackUrl); + } else { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error: unknown) { + // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) + console.error(error); + if ( + error && + typeof error === "object" && + "code" in error && + error?.code === 9 + ) { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: selectedSession.factors?.user?.organizationId, + }); + + if (loginSettings?.defaultRedirectUri) { + return NextResponse.redirect(loginSettings.defaultRedirectUri); + } + + const signedinUrl = constructUrl(request, "/signedin"); + + if (selectedSession.factors?.user?.loginName) { + signedinUrl.searchParams.set( + "loginName", + selectedSession.factors?.user?.loginName, + ); + } + if (selectedSession.factors?.user?.organizationId) { + signedinUrl.searchParams.set( + "organization", + selectedSession.factors?.user?.organizationId, + ); + } + return NextResponse.redirect(signedinUrl); + } else { + return NextResponse.json({ error }, { status: 500 }); + } + } + } + } +} diff --git a/login/apps/login/src/lib/saml.ts b/login/apps/login/src/lib/saml.ts new file mode 100644 index 0000000000..e85084f022 --- /dev/null +++ b/login/apps/login/src/lib/saml.ts @@ -0,0 +1,130 @@ +import { Cookie } from "@/lib/cookies"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { createResponse, getLoginSettings } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { NextRequest, NextResponse } from "next/server"; +import { constructUrl } from "./service-url"; +import { isSessionValid } from "./session"; + +type LoginWithSAMLAndSession = { + serviceUrl: string; + samlRequest: string; + sessionId: string; + sessions: Session[]; + sessionCookies: Cookie[]; + request: NextRequest; +}; + +export async function loginWithSAMLAndSession({ + serviceUrl, + samlRequest, + sessionId, + sessions, + sessionCookies, + request, +}: LoginWithSAMLAndSession) { + console.log( + `Login with session: ${sessionId} and samlRequest: ${samlRequest}`, + ); + + const selectedSession = sessions.find((s) => s.id === sessionId); + + if (selectedSession && selectedSession.id) { + console.log(`Found session ${selectedSession.id}`); + + const isValid = await isSessionValid({ + serviceUrl, + session: selectedSession, + }); + + console.log("Session is valid:", isValid); + + if (!isValid && selectedSession.factors?.user) { + // if the session is not valid anymore, we need to redirect the user to re-authenticate / + // TODO: handle IDP intent direcly if available + const command: SendLoginnameCommand = { + loginName: selectedSession.factors.user?.loginName, + organization: selectedSession.factors?.user?.organizationId, + requestId: `saml_${samlRequest}`, + }; + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const { url } = await createResponse({ + serviceUrl, + req: create(CreateResponseRequestSchema, { + samlRequestId: samlRequest, + responseKind: { + case: "session", + value: session, + }, + }), + }); + if (url) { + return NextResponse.redirect(url); + } else { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error: unknown) { + // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) + console.error(error); + if ( + error && + typeof error === "object" && + "code" in error && + error?.code === 9 + ) { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: selectedSession.factors?.user?.organizationId, + }); + + if (loginSettings?.defaultRedirectUri) { + return NextResponse.redirect(loginSettings.defaultRedirectUri); + } + + const signedinUrl = constructUrl(request, "/signedin"); + + if (selectedSession.factors?.user?.loginName) { + signedinUrl.searchParams.set( + "loginName", + selectedSession.factors?.user?.loginName, + ); + } + if (selectedSession.factors?.user?.organizationId) { + signedinUrl.searchParams.set( + "organization", + selectedSession.factors?.user?.organizationId, + ); + } + return NextResponse.redirect(signedinUrl); + } else { + return NextResponse.json({ error }, { status: 500 }); + } + } + } + } +} diff --git a/login/apps/login/src/lib/self.ts b/login/apps/login/src/lib/self.ts new file mode 100644 index 0000000000..df8508c29e --- /dev/null +++ b/login/apps/login/src/lib/self.ts @@ -0,0 +1,60 @@ +"use server"; + +import { createUserServiceClient } from "@zitadel/client/v2"; +import { headers } from "next/headers"; +import { getSessionCookieById } from "./cookies"; +import { getServiceUrlFromHeaders } from "./service-url"; +import { createServerTransport, getSession } from "./zitadel"; + +const myUserService = async (serviceUrl: string, sessionToken: string) => { + const transportPromise = await createServerTransport( + sessionToken, + serviceUrl, + ); + return createUserServiceClient(transportPromise); +}; + +export async function setMyPassword({ + sessionId, + password, +}: { + sessionId: string; + password: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const { session } = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session) { + return { error: "Could not load session" }; + } + + const service = await myUserService(serviceUrl, `${sessionCookie.token}`); + + if (!session?.factors?.user?.id) { + return { error: "No user id found in session" }; + } + + return service + .setPassword( + { + userId: session.factors.user.id, + newPassword: { password, changeRequired: false }, + }, + {}, + ) + .catch((error) => { + console.log(error); + if (error.code === 7) { + return { error: "Session is not valid." }; + } + throw error; + }); +} diff --git a/login/apps/login/src/lib/server/cookie.ts b/login/apps/login/src/lib/server/cookie.ts new file mode 100644 index 0000000000..841fc06b3a --- /dev/null +++ b/login/apps/login/src/lib/server/cookie.ts @@ -0,0 +1,278 @@ +"use server"; + +import { addSessionToCookie, updateSessionCookie } from "@/lib/cookies"; +import { + createSessionForUserIdAndIdpIntent, + createSessionFromChecks, + getSecuritySettings, + getSession, + setSession, +} from "@/lib/zitadel"; +import { ConnectError, Duration, timestampMs } from "@zitadel/client"; +import { + CredentialsCheckError, + CredentialsCheckErrorSchema, + ErrorDetail, +} from "@zitadel/proto/zitadel/message_pb"; +import { + Challenges, + RequestChallenges, +} from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +type CustomCookieData = { + id: string; + token: string; + loginName: string; + organization?: string; + creationTs: string; + expirationTs: string; + changeTs: string; + requestId?: string; // if its linked to an OIDC flow +}; + +const passwordAttemptsHandler = (error: ConnectError) => { + const details = error.findDetails(CredentialsCheckErrorSchema); + + if (details[0] && "failedAttempts" in details[0]) { + const failedAttempts = details[0].failedAttempts; + throw { + error: `Failed to authenticate: You had ${failedAttempts} password attempts.`, + failedAttempts: failedAttempts, + }; + } + throw error; +}; + +export async function createSessionAndUpdateCookie(command: { + checks: Checks; + requestId: string | undefined; + lifetime?: Duration; +}): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const createdSession = await createSessionFromChecks({ + serviceUrl, + checks: command.checks, + lifetime: command.lifetime, + }); + + if (createdSession) { + return getSession({ + serviceUrl, + sessionId: createdSession.sessionId, + sessionToken: createdSession.sessionToken, + }).then(async (response) => { + if (response?.session && response.session?.factors?.user?.loginName) { + const sessionCookie: CustomCookieData = { + id: createdSession.sessionId, + token: createdSession.sessionToken, + creationTs: response.session.creationDate + ? `${timestampMs(response.session.creationDate)}` + : "", + expirationTs: response.session.expirationDate + ? `${timestampMs(response.session.expirationDate)}` + : "", + changeTs: response.session.changeDate + ? `${timestampMs(response.session.changeDate)}` + : "", + loginName: response.session.factors.user.loginName ?? "", + }; + + if (command.requestId) { + sessionCookie.requestId = command.requestId; + } + + if (response.session.factors.user.organizationId) { + sessionCookie.organization = + response.session.factors.user.organizationId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + await addSessionToCookie({ session: sessionCookie, sameSite }); + + return response.session as Session; + } else { + throw "could not get session or session does not have loginName"; + } + }); + } else { + throw "Could not create session"; + } +} + +export async function createSessionForIdpAndUpdateCookie({ + userId, + idpIntent, + requestId, + lifetime, +}: { + userId: string; + idpIntent: { + idpIntentId?: string | undefined; + idpIntentToken?: string | undefined; + }; + requestId: string | undefined; + lifetime?: Duration; +}): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const createdSession = await createSessionForUserIdAndIdpIntent({ + serviceUrl, + userId, + idpIntent, + lifetime, + }).catch((error: ErrorDetail | CredentialsCheckError) => { + console.error("Could not set session", error); + if ("failedAttempts" in error && error.failedAttempts) { + throw { + error: `Failed to authenticate: You had ${error.failedAttempts} password attempts.`, + failedAttempts: error.failedAttempts, + }; + } + throw error; + }); + + if (!createdSession) { + throw "Could not create session"; + } + + const { session } = await getSession({ + serviceUrl, + sessionId: createdSession.sessionId, + sessionToken: createdSession.sessionToken, + }); + + if (!session || !session.factors?.user?.loginName) { + throw "Could not retrieve session"; + } + + const sessionCookie: CustomCookieData = { + id: createdSession.sessionId, + token: createdSession.sessionToken, + creationTs: session.creationDate + ? `${timestampMs(session.creationDate)}` + : "", + expirationTs: session.expirationDate + ? `${timestampMs(session.expirationDate)}` + : "", + changeTs: session.changeDate ? `${timestampMs(session.changeDate)}` : "", + loginName: session.factors.user.loginName ?? "", + organization: session.factors.user.organizationId ?? "", + }; + + if (requestId) { + sessionCookie.requestId = requestId; + } + + if (session.factors.user.organizationId) { + sessionCookie.organization = session.factors.user.organizationId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + + return addSessionToCookie({ session: sessionCookie, sameSite }).then(() => { + return session as Session; + }); +} + +export type SessionWithChallenges = Session & { + challenges: Challenges | undefined; +}; + +export async function setSessionAndUpdateCookie( + recentCookie: CustomCookieData, + checks?: Checks, + challenges?: RequestChallenges, + requestId?: string, + lifetime?: Duration, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return setSession({ + serviceUrl, + sessionId: recentCookie.id, + sessionToken: recentCookie.token, + challenges, + checks, + lifetime, + }) + .then((updatedSession) => { + if (updatedSession) { + const sessionCookie: CustomCookieData = { + id: recentCookie.id, + token: updatedSession.sessionToken, + creationTs: recentCookie.creationTs, + expirationTs: recentCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: recentCookie.loginName, + organization: recentCookie.organization, + }; + + if (requestId) { + sessionCookie.requestId = requestId; + } + + return getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then(async (response) => { + if ( + !response?.session || + !response.session.factors?.user?.loginName + ) { + throw "could not get session or session does not have loginName"; + } + + const { session } = response; + const newCookie: CustomCookieData = { + id: sessionCookie.id, + token: updatedSession.sessionToken, + creationTs: sessionCookie.creationTs, + expirationTs: sessionCookie.expirationTs, + // just overwrite the changeDate with the new one + changeTs: updatedSession.details?.changeDate + ? `${timestampMs(updatedSession.details.changeDate)}` + : "", + loginName: session.factors?.user?.loginName ?? "", + organization: session.factors?.user?.organizationId ?? "", + }; + + if (sessionCookie.requestId) { + newCookie.requestId = sessionCookie.requestId; + } + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled + ? "none" + : true; + + return updateSessionCookie({ + id: sessionCookie.id, + session: newCookie, + sameSite, + }).then(() => { + return { challenges: updatedSession.challenges, ...session }; + }); + }); + } else { + throw "Session not be set"; + } + }) + .catch(passwordAttemptsHandler); +} diff --git a/login/apps/login/src/lib/server/device.ts b/login/apps/login/src/lib/server/device.ts new file mode 100644 index 0000000000..5e36facfc8 --- /dev/null +++ b/login/apps/login/src/lib/server/device.ts @@ -0,0 +1,20 @@ +"use server"; + +import { authorizeOrDenyDeviceAuthorization } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function completeDeviceAuthorization( + deviceAuthorizationId: string, + session?: { sessionId: string; sessionToken: string }, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // without the session, device auth request is denied + return authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, + }); +} diff --git a/login/apps/login/src/lib/server/idp.ts b/login/apps/login/src/lib/server/idp.ts new file mode 100644 index 0000000000..87f88a7c32 --- /dev/null +++ b/login/apps/login/src/lib/server/idp.ts @@ -0,0 +1,241 @@ +"use server"; + +import { + getLoginSettings, + getUserByID, + startIdentityProviderFlow, + startLDAPIdentityProviderFlow, +} from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { getNextUrl } from "../client"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { checkEmailVerification } from "../verify-helper"; +import { createSessionForIdpAndUpdateCookie } from "./cookie"; + +export type RedirectToIdpState = { error?: string | null } | undefined; + +export async function redirectToIdp( + prevState: RedirectToIdpState, + formData: FormData, +): Promise { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + if (!host) { + return { error: "Could not get host" }; + } + + const params = new URLSearchParams(); + + const linkOnly = formData.get("linkOnly") === "true"; + const requestId = formData.get("requestId") as string; + const organization = formData.get("organization") as string; + const idpId = formData.get("id") as string; + const provider = formData.get("provider") as string; + + if (linkOnly) params.set("link", "true"); + if (requestId) params.set("requestId", requestId); + if (organization) params.set("organization", organization); + + // redirect to LDAP page where username and password is requested + if (provider === "ldap") { + params.set("idpId", idpId); + redirect(`/idp/ldap?` + params.toString()); + } + + const response = await startIDPFlow({ + serviceUrl, + host, + idpId, + successUrl: `/idp/${provider}/success?` + params.toString(), + failureUrl: `/idp/${provider}/failure?` + params.toString(), + }); + + if (!response) { + return { error: "Could not start IDP flow" }; + } + + if (response && "redirect" in response && response?.redirect) { + redirect(response.redirect); + } + + return { error: "Unexpected response from IDP flow" }; +} + +export type StartIDPFlowCommand = { + serviceUrl: string; + host: string; + idpId: string; + successUrl: string; + failureUrl: string; +}; + +async function startIDPFlow(command: StartIDPFlowCommand) { + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const url = await startIdentityProviderFlow({ + serviceUrl: command.serviceUrl, + idpId: command.idpId, + urls: { + successUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.successUrl}`, + failureUrl: `${command.host.includes("localhost") ? "http://" : "https://"}${command.host}${basePath}${command.failureUrl}`, + }, + }); + + if (!url) { + return { error: "Could not start IDP flow" }; + } + + return { redirect: url }; +} + +type CreateNewSessionCommand = { + userId: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + loginName?: string; + password?: string; + organization?: string; + requestId?: string; +}; + +export async function createNewSessionFromIdpIntent( + command: CreateNewSessionCommand, +) { + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get domain" }; + } + + if (!command.userId || !command.idpIntent) { + throw new Error("No userId or loginName provided"); + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!userResponse || !userResponse.user) { + return { error: "User not found in the system" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: userResponse.user.details?.resourceOwner, + }); + + const session = await createSessionForIdpAndUpdateCookie({ + userId: command.userId, + idpIntent: command.idpIntent, + requestId: command.requestId, + lifetime: loginSettings?.externalLoginCheckLifetime, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + // check to see if user was verified + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + command.organization, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + // TODO: check if user has MFA methods + // const mfaFactorCheck = checkMFAFactors(session, loginSettings, authMethods, organization, requestId); + // if (mfaFactorCheck?.redirect) { + // return mfaFactorCheck; + // } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + if (url) { + return { redirect: url }; + } +} + +type createNewSessionForLDAPCommand = { + username: string; + password: string; + idpId: string; + link: boolean; +}; + +export async function createNewSessionForLDAP( + command: createNewSessionForLDAPCommand, +) { + const _headers = await headers(); + + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get domain" }; + } + + if (!command.username || !command.password) { + return { error: "No username or password provided" }; + } + + const response = await startLDAPIdentityProviderFlow({ + serviceUrl, + idpId: command.idpId, + username: command.username, + password: command.password, + }); + + if ( + !response || + response.nextStep.case !== "idpIntent" || + !response.nextStep.value + ) { + return { error: "Could not start LDAP identity provider flow" }; + } + + const { userId, idpIntentId, idpIntentToken } = response.nextStep.value; + + const params = new URLSearchParams({ + userId, + id: idpIntentId, + token: idpIntentToken, + }); + + if (command.link) { + params.set("link", "true"); + } + + return { + redirect: `/idp/ldap/success?` + params.toString(), + }; +} diff --git a/login/apps/login/src/lib/server/loginname.ts b/login/apps/login/src/lib/server/loginname.ts new file mode 100644 index 0000000000..68cb345c06 --- /dev/null +++ b/login/apps/login/src/lib/server/loginname.ts @@ -0,0 +1,454 @@ +"use server"; + +import { create } from "@zitadel/client"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { idpTypeToIdentityProviderType, idpTypeToSlug } from "../idp"; + +import { PasskeysType } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + getActiveIdentityProviders, + getIDPByID, + getLoginSettings, + getOrgsByDomain, + listAuthenticationMethodTypes, + listIDPLinks, + searchUsers, + SearchUsersCommand, + startIdentityProviderFlow, +} from "../zitadel"; +import { createSessionAndUpdateCookie } from "./cookie"; + +export type SendLoginnameCommand = { + loginName: string; + requestId?: string; + organization?: string; + suffix?: string; +}; + +const ORG_SUFFIX_REGEX = /(?<=@)(.+)/; + +export async function sendLoginname(command: SendLoginnameCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + throw new Error("Could not get domain"); + } + + const loginSettingsByContext = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + if (!loginSettingsByContext) { + return { error: "Could not get login settings" }; + } + + let searchUsersRequest: SearchUsersCommand = { + serviceUrl, + searchValue: command.loginName, + organizationId: command.organization, + loginSettings: loginSettingsByContext, + suffix: command.suffix, + }; + + const searchResult = await searchUsers(searchUsersRequest); + + if ("error" in searchResult && searchResult.error) { + return searchResult; + } + + if (!("result" in searchResult)) { + return { error: "Could not search users" }; + } + + const { result: potentialUsers } = searchResult; + + const redirectUserToSingleIDPIfAvailable = async () => { + const identityProviders = await getActiveIdentityProviders({ + serviceUrl, + orgId: command.organization, + }).then((resp) => { + return resp.identityProviders; + }); + + if (identityProviders.length === 1) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const identityProviderType = identityProviders[0].type; + + const provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams(); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.organization) { + params.set("organization", command.organization); + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const url = await startIdentityProviderFlow({ + serviceUrl, + idpId: identityProviders[0].id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (!url) { + return { error: "Could not start IDP flow" }; + } + + return { redirect: url }; + } + }; + + const redirectUserToIDP = async (userId: string) => { + const identityProviders = await listIDPLinks({ + serviceUrl, + userId, + }).then((resp) => { + return resp.result; + }); + + if (identityProviders.length === 1) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + const identityProviderId = identityProviders[0].idpId; + + const idp = await getIDPByID({ + serviceUrl, + id: identityProviderId, + }); + + const idpType = idp?.type; + + if (!idp || !idpType) { + throw new Error("Could not find identity provider"); + } + + const identityProviderType = idpTypeToIdentityProviderType(idpType); + const provider = idpTypeToSlug(identityProviderType); + + const params = new URLSearchParams({ userId }); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.organization) { + params.set("organization", command.organization); + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + const url = await startIdentityProviderFlow({ + serviceUrl, + idpId: idp.id, + urls: { + successUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/success?` + + new URLSearchParams(params), + failureUrl: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/idp/${provider}/failure?` + + new URLSearchParams(params), + }, + }); + + if (!url) { + return { error: "Could not start IDP flow" }; + } + + return { redirect: url }; + } + }; + + if (potentialUsers.length > 1) { + return { error: "More than one user found. Provide a unique identifier." }; + } else if (potentialUsers.length == 1 && potentialUsers[0].userId) { + const user = potentialUsers[0]; + const userId = potentialUsers[0].userId; + + const userLoginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + + // compare with the concatenated suffix when set + const concatLoginname = command.suffix + ? `${command.loginName}@${command.suffix}` + : command.loginName; + + const humanUser = + potentialUsers[0].type.case === "human" + ? potentialUsers[0].type.value + : undefined; + + // recheck login settings after user discovery, as the search might have been done without org scope + if ( + userLoginSettings?.disableLoginWithEmail && + userLoginSettings?.disableLoginWithPhone + ) { + if (user.preferredLoginName !== concatLoginname) { + return { error: "User not found in the system!" }; + } + } else if (userLoginSettings?.disableLoginWithEmail) { + if ( + user.preferredLoginName !== concatLoginname || + humanUser?.phone?.phone !== command.loginName + ) { + return { error: "User not found in the system!" }; + } + } else if (userLoginSettings?.disableLoginWithPhone) { + if ( + user.preferredLoginName !== concatLoginname || + humanUser?.email?.email !== command.loginName + ) { + return { error: "User not found in the system!" }; + } + } + + const checks = create(ChecksSchema, { + user: { search: { case: "userId", value: userId } }, + }); + + const session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + + if (!session.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + // TODO: check if handling of userstate INITIAL is needed + if (user.state === UserState.INITIAL) { + return { error: "Initial User not supported" }; + } + + const methods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors?.user?.id, + }); + + // always resend invite if user has no auth method set + if (!methods.authMethodTypes || !methods.authMethodTypes.length) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true to request a new code immediately + invite: "true", + }); + + if (command.requestId) { + params.append("requestId", command.requestId); + } + + if (command.organization || session.factors?.user?.organizationId) { + params.append( + "organization", + command.organization ?? + (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify?` + params }; + } + + if (methods.authMethodTypes.length == 1) { + const method = methods.authMethodTypes[0]; + switch (method) { + case AuthenticationMethodType.PASSWORD: // user has only password as auth method + if (!userLoginSettings?.allowUsernamePassword) { + return { + error: + "Username Password not allowed! Contact your administrator for more information.", + }; + } + + const paramsPassword: any = { + loginName: session.factors?.user?.loginName, + }; + + // TODO: does this have to be checked in loginSettings.allowDomainDiscovery + + if (command.organization || session.factors?.user?.organizationId) { + paramsPassword.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + if (command.requestId) { + paramsPassword.requestId = command.requestId; + } + + return { + redirect: "/password?" + new URLSearchParams(paramsPassword), + }; + + case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY + if (userLoginSettings?.passkeysType === PasskeysType.NOT_ALLOWED) { + return { + error: + "Passkeys not allowed! Contact your administrator for more information.", + }; + } + + const paramsPasskey: any = { loginName: command.loginName }; + if (command.requestId) { + paramsPasskey.requestId = command.requestId; + } + + if (command.organization || session.factors?.user?.organizationId) { + paramsPasskey.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + return { redirect: "/passkey?" + new URLSearchParams(paramsPasskey) }; + } + } else { + // prefer passkey in favor of other methods + if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) { + const passkeyParams: any = { + loginName: command.loginName, + altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option + }; + + if (command.requestId) { + passkeyParams.requestId = command.requestId; + } + + if (command.organization || session.factors?.user?.organizationId) { + passkeyParams.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + return { redirect: "/passkey?" + new URLSearchParams(passkeyParams) }; + } else if ( + methods.authMethodTypes.includes(AuthenticationMethodType.IDP) + ) { + return redirectUserToIDP(userId); + } else if ( + methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) + ) { + // user has no passkey setup and login settings allow passkeys + const paramsPasswordDefault: any = { loginName: command.loginName }; + + if (command.requestId) { + paramsPasswordDefault.requestId = command.requestId; + } + + if (command.organization || session.factors?.user?.organizationId) { + paramsPasswordDefault.organization = + command.organization ?? session.factors?.user?.organizationId; + } + + return { + redirect: "/password?" + new URLSearchParams(paramsPasswordDefault), + }; + } + } + } + + // user not found, check if register is enabled on instance / organization context + if ( + loginSettingsByContext?.allowRegister && + !loginSettingsByContext?.allowUsernamePassword + ) { + const resp = await redirectUserToSingleIDPIfAvailable(); + if (resp) { + return resp; + } + return { error: "User not found in the system" }; + } else if ( + loginSettingsByContext?.allowRegister && + loginSettingsByContext?.allowUsernamePassword + ) { + let orgToRegisterOn: string | undefined = command.organization; + + if ( + !loginSettingsByContext?.ignoreUnknownUsernames && + !orgToRegisterOn && + command.loginName && + ORG_SUFFIX_REGEX.test(command.loginName) + ) { + const matched = ORG_SUFFIX_REGEX.exec(command.loginName); + const suffix = matched?.[1] ?? ""; + + // this just returns orgs where the suffix is set as primary domain + const orgs = await getOrgsByDomain({ + serviceUrl, + domain: suffix, + }); + const orgToCheckForDiscovery = + orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined; + + const orgLoginSettings = await getLoginSettings({ + serviceUrl, + organization: orgToCheckForDiscovery, + }); + if (orgLoginSettings?.allowDomainDiscovery) { + orgToRegisterOn = orgToCheckForDiscovery; + } + } + + // do not register user if ignoreUnknownUsernames is set + if (orgToRegisterOn && !loginSettingsByContext?.ignoreUnknownUsernames) { + const params = new URLSearchParams({ organization: orgToRegisterOn }); + + if (command.requestId) { + params.set("requestId", command.requestId); + } + + if (command.loginName) { + params.set("email", command.loginName); + } + + return { redirect: "/register?" + params }; + } + } + + if (loginSettingsByContext?.ignoreUnknownUsernames) { + const paramsPasswordDefault = new URLSearchParams({ + loginName: command.loginName, + }); + + if (command.requestId) { + paramsPasswordDefault.append("requestId", command.requestId); + } + + if (command.organization) { + paramsPasswordDefault.append("organization", command.organization); + } + + return { redirect: "/password?" + paramsPasswordDefault }; + } + + // fallbackToPassword + + return { error: "User not found in the system" }; +} diff --git a/login/apps/login/src/lib/server/oidc.ts b/login/apps/login/src/lib/server/oidc.ts new file mode 100644 index 0000000000..36a31fe419 --- /dev/null +++ b/login/apps/login/src/lib/server/oidc.ts @@ -0,0 +1,15 @@ +"use server"; + +import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function getDeviceAuthorizationRequest(userCode: string) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return zitadelGetDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); +} diff --git a/login/apps/login/src/lib/server/otp.ts b/login/apps/login/src/lib/server/otp.ts new file mode 100644 index 0000000000..f3d4a1536a --- /dev/null +++ b/login/apps/login/src/lib/server/otp.ts @@ -0,0 +1,83 @@ +"use server"; + +import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; +import { create } from "@zitadel/client"; +import { + CheckOTPSchema, + ChecksSchema, + CheckTOTPSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { getLoginSettings } from "../zitadel"; + +export type SetOTPCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + requestId?: string; + code: string; + method: string; +}; + +export async function setOTP(command: SetOTPCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const recentSession = command.sessionId + ? await getSessionCookieById({ sessionId: command.sessionId }).catch( + (error) => { + return Promise.reject(error); + }, + ) + : command.loginName + ? await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + return Promise.reject(error); + }) + : await getMostRecentSessionCookie().catch((error) => { + return Promise.reject(error); + }); + + const checks = create(ChecksSchema, {}); + + if (command.method === "time-based") { + checks.totp = create(CheckTOTPSchema, { + code: command.code, + }); + } else if (command.method === "sms") { + checks.otpSms = create(CheckOTPSchema, { + code: command.code, + }); + } else if (command.method === "email") { + checks.otpEmail = create(CheckOTPSchema, { + code: command.code, + }); + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + return setSessionAndUpdateCookie( + recentSession, + checks, + undefined, + command.requestId, + loginSettings?.secondFactorCheckLifetime, + ).then((session) => { + return { + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + }; + }); +} diff --git a/login/apps/login/src/lib/server/passkeys.ts b/login/apps/login/src/lib/server/passkeys.ts new file mode 100644 index 0000000000..3470629f24 --- /dev/null +++ b/login/apps/login/src/lib/server/passkeys.ts @@ -0,0 +1,278 @@ +"use server"; + +import { + createPasskeyRegistrationLink, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + registerPasskey, + verifyPasskeyRegistration as zitadelVerifyPasskeyRegistration, +} from "@/lib/zitadel"; +import { create, Duration, Timestamp, timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { + RegisterPasskeyResponse, + VerifyPasskeyRegistrationRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { userAgent } from "next/server"; +import { getNextUrl } from "../client"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + checkEmailVerification, + checkUserVerification, +} from "../verify-helper"; +import { setSessionAndUpdateCookie } from "./cookie"; + +type VerifyPasskeyCommand = { + passkeyId: string; + passkeyName?: string; + publicKeyCredential: any; + sessionId: string; +}; + +type RegisterPasskeyCommand = { + sessionId: string; +}; + +function isSessionValid(session: Partial): { + valid: boolean; + verifiedAt?: Timestamp; +} { + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate) > new Date() + : true; + + const verifiedAt = validPassword || validPasskey; + const valid = !!((validPassword || validPasskey) && stillValid); + + return { valid, verifiedAt }; +} + +export async function registerPasskeyLink( + command: RegisterPasskeyCommand, +): Promise { + const { sessionId } = command; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + throw new Error("Could not get domain"); + } + + const sessionCookie = await getSessionCookieById({ sessionId }); + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session?.session?.factors?.user?.id) { + return { error: "Could not determine user from session" }; + } + + const sessionValid = isSessionValid(session.session); + + if (!sessionValid) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.session.factors.user.id, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to authenticate or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + session.session.factors.user.id, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + + const [hostname, port] = host.split(":"); + + if (!hostname) { + throw new Error("Could not get hostname"); + } + + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + // TODO: add org context + + // use session token to add the passkey + const registerLink = await createPasskeyRegistrationLink({ + serviceUrl, + userId, + }); + + if (!registerLink.code) { + throw new Error("Missing code in response"); + } + + return registerPasskey({ + serviceUrl, + userId, + code: registerLink.code, + domain: hostname, + }); +} + +export async function verifyPasskeyRegistration(command: VerifyPasskeyCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // if no name is provided, try to generate one from the user agent + let passkeyName = command.passkeyName; + if (!!!passkeyName) { + const headersList = await headers(); + const userAgentStructure = { headers: headersList }; + const { browser, device, os } = userAgent(userAgentStructure); + + passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ + device.vendor || device.model ? ", " : "" + }${os.name}${os.name ? ", " : ""}${browser.name}`; + } + + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + + return zitadelVerifyPasskeyRegistration({ + serviceUrl, + request: create(VerifyPasskeyRegistrationRequestSchema, { + passkeyId: command.passkeyId, + publicKeyCredential: command.publicKeyCredential, + passkeyName, + userId, + }), + }); +} + +type SendPasskeyCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + checks?: Checks; + requestId?: string; + lifetime?: Duration; +}; + +export async function sendPasskey(command: SendPasskeyCommand) { + let { loginName, sessionId, organization, checks, requestId } = command; + const recentSession = sessionId + ? await getSessionCookieById({ sessionId }) + : loginName + ? await getSessionCookieByLoginName({ loginName, organization }) + : await getMostRecentSessionCookie(); + + if (!recentSession) { + return { + error: "Could not find session", + }; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const lifetime = checks?.webAuthN + ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey + : checks?.otpEmail || checks?.otpSms + ? loginSettings?.secondFactorCheckLifetime + : undefined; + + const session = await setSessionAndUpdateCookie( + recentSession, + checks, + undefined, + requestId, + lifetime, + ); + + if (!session || !session?.factors?.user?.id) { + return { error: "Could not update session" }; + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + organization, + requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + const url = + requestId && session.id + ? await getNextUrl( + { + sessionId: session.id, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : session?.factors?.user?.loginName + ? await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + + return { redirect: url }; +} diff --git a/login/apps/login/src/lib/server/password.ts b/login/apps/login/src/lib/server/password.ts new file mode 100644 index 0000000000..5c6fb03aa5 --- /dev/null +++ b/login/apps/login/src/lib/server/password.ts @@ -0,0 +1,460 @@ +"use server"; + +import { + createSessionAndUpdateCookie, + setSessionAndUpdateCookie, +} from "@/lib/server/cookie"; +import { + getLockoutSettings, + getLoginSettings, + getPasswordExpirySettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + listUsers, + passwordReset, + setPassword, + setUserPassword, +} from "@/lib/zitadel"; +import { ConnectError, create } from "@zitadel/client"; +import { createUserServiceClient } from "@zitadel/client/v2"; +import { + Checks, + ChecksSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { User, UserState } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { + AuthenticationMethodType, + SetPasswordRequestSchema, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getSessionCookieById, getSessionCookieByLoginName } from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { + checkEmailVerification, + checkMFAFactors, + checkPasswordChangeRequired, + checkUserVerification, +} from "../verify-helper"; +import { createServerTransport } from "../zitadel"; + +type ResetPasswordCommand = { + loginName: string; + organization?: string; + requestId?: string; +}; + +export async function resetPassword(command: ResetPasswordCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const users = await listUsers({ + serviceUrl, + loginName: command.loginName, + organizationId: command.organization, + }); + + if ( + !users.details || + users.details.totalResult !== BigInt(1) || + !users.result[0].userId + ) { + return { error: "Could not send Password Reset Link" }; + } + const userId = users.result[0].userId; + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + return passwordReset({ + serviceUrl, + userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/password/set?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }); +} + +export type UpdateSessionCommand = { + loginName: string; + organization?: string; + checks: Checks; + requestId?: string; +}; + +export async function sendPassword(command: UpdateSessionCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let sessionCookie = await getSessionCookieByLoginName({ + loginName: command.loginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); + }); + + let session; + let user: User; + let loginSettings: LoginSettings | undefined; + + if (!sessionCookie) { + const users = await listUsers({ + serviceUrl, + loginName: command.loginName, + organizationId: command.organization, + }); + + if (users.details?.totalResult == BigInt(1) && users.result[0].userId) { + user = users.result[0]; + + const checks = create(ChecksSchema, { + user: { search: { case: "userId", value: users.result[0].userId } }, + password: { password: command.checks.password?.password }, + }); + + loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + try { + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + lifetime: loginSettings?.passwordCheckLifetime, + }); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + orgId: command.organization, + }); + + return { + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? "Contact your administrator to unlock your account" + : ""), + }; + } + return { error: "Could not create session for user" }; + } + } + + // this is a fake error message to hide that the user does not even exist + return { error: "Could not verify password" }; + } else { + try { + session = await setSessionAndUpdateCookie( + sessionCookie, + command.checks, + undefined, + command.requestId, + loginSettings?.passwordCheckLifetime, + ); + } catch (error: any) { + if ("failedAttempts" in error && error.failedAttempts) { + const lockoutSettings = await getLockoutSettings({ + serviceUrl, + orgId: command.organization, + }); + + return { + error: + `Failed to authenticate. You had ${error.failedAttempts} of ${lockoutSettings?.maxPasswordAttempts} password attempts.` + + (lockoutSettings?.maxPasswordAttempts && + error.failedAttempts >= lockoutSettings?.maxPasswordAttempts + ? " Contact your administrator to unlock your account" + : ""), + }; + } + throw error; + } + + if (!session?.factors?.user?.id) { + return { error: "Could not create session for user" }; + } + + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + user = userResponse.user; + } + + if (!loginSettings) { + loginSettings = await getLoginSettings({ + serviceUrl, + organization: + command.organization ?? session.factors?.user?.organizationId, + }); + } + + if (!session?.factors?.user?.id || !sessionCookie) { + return { error: "Could not create session for user" }; + } + + const humanUser = user.type.case === "human" ? user.type.value : undefined; + + const expirySettings = await getPasswordExpirySettings({ + serviceUrl, + orgId: command.organization ?? session.factors?.user?.organizationId, + }); + + // check if the user has to change password first + const passwordChangedCheck = checkPasswordChangeRequired( + expirySettings, + session, + humanUser, + command.organization, + command.requestId, + ); + + if (passwordChangedCheck?.redirect) { + return passwordChangedCheck; + } + + // throw error if user is in initial state here and do not continue + if (user.state === UserState.INITIAL) { + return { error: "Initial User not supported" }; + } + + // check to see if user was verified + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + command.organization, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + // if password, check if user has MFA methods + let authMethods; + if (command.checks && command.checks.password && session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; + } + } + + if (!authMethods) { + return { error: "Could not verify password!" }; + } + + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethods, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + + if (command.requestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + requestId: command.requestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} + +// this function lets users with code set a password or users with valid User Verification Check +export async function changePassword(command: { + code?: string; + userId: string; + password: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // check for init state + const { user } = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!user || user.userId !== command.userId) { + return { error: "Could not send Password Reset Link" }; + } + const userId = user.userId; + + if (user.state === UserState.INITIAL) { + return { error: "User Initial State is not supported" }; + } + + // check if the user has no password set in order to set a password + if (!command.code) { + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId, + }); + + // if the user has no authmethods set, we need to check if the user was verified + if (authmethods.authMethodTypes.length !== 0) { + return { + error: + "You have to provide a code or have a valid User Verification Check", + }; + } + + // check if a verification was done earlier + const hasValidUserVerificationCheck = await checkUserVerification( + user.userId, + ); + + if (!hasValidUserVerificationCheck) { + return { error: "User Verification Check has to be done" }; + } + } + + return setUserPassword({ + serviceUrl, + userId, + password: command.password, + code: command.code, + }); +} + +type CheckSessionAndSetPasswordCommand = { + sessionId: string; + password: string; +}; + +export async function checkSessionAndSetPassword({ + sessionId, + password, +}: CheckSessionAndSetPasswordCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const { session } = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + if (!session || !session.factors?.user?.id) { + return { error: "Could not load session" }; + } + + const payload = create(SetPasswordRequestSchema, { + userId: session.factors.user.id, + newPassword: { + password, + }, + }); + + // check if the user has no password set in order to set a password + const authmethods = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + + if (!authmethods) { + return { error: "Could not load auth methods" }; + } + + const requiredAuthMethodsForForceMFA = [ + AuthenticationMethodType.OTP_EMAIL, + AuthenticationMethodType.OTP_SMS, + AuthenticationMethodType.TOTP, + AuthenticationMethodType.U2F, + ]; + + const hasNoMFAMethods = requiredAuthMethodsForForceMFA.every( + (method) => !authmethods.authMethodTypes.includes(method), + ); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors.user.organizationId, + }); + + const forceMfa = !!( + loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly + ); + + // if the user has no MFA but MFA is enforced, we can set a password otherwise we use the token of the user + if (forceMfa && hasNoMFAMethods) { + return setPassword({ serviceUrl, payload }).catch((error) => { + // throw error if failed precondition (ex. User is not yet initialized) + if (error.code === 9 && error.message) { + return { error: "Failed precondition" }; + } else { + throw error; + } + }); + } else { + const transport = async (serviceUrl: string, token: string) => { + return createServerTransport(token, serviceUrl); + }; + + const myUserService = async (serviceUrl: string, sessionToken: string) => { + const transportPromise = await transport(serviceUrl, sessionToken); + return createUserServiceClient(transportPromise); + }; + + const selfService = await myUserService( + serviceUrl, + `${sessionCookie.token}`, + ); + + return selfService + .setPassword( + { + userId: session.factors.user.id, + newPassword: { password, changeRequired: false }, + }, + {}, + ) + .catch((error: ConnectError) => { + console.log(error); + if (error.code === 7) { + return { error: "Session is not valid." }; + } + throw error; + }); + } +} diff --git a/login/apps/login/src/lib/server/register.ts b/login/apps/login/src/lib/server/register.ts new file mode 100644 index 0000000000..f84b4c8d51 --- /dev/null +++ b/login/apps/login/src/lib/server/register.ts @@ -0,0 +1,233 @@ +"use server"; + +import { + createSessionAndUpdateCookie, + createSessionForIdpAndUpdateCookie, +} from "@/lib/server/cookie"; +import { + addHumanUser, + addIDPLink, + getLoginSettings, + getUserByID, +} from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { Factors } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { + ChecksJson, + ChecksSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { checkEmailVerification } from "../verify-helper"; + +type RegisterUserCommand = { + email: string; + firstName: string; + lastName: string; + password?: string; + organization: string; + requestId?: string; +}; + +export type RegisterUserResponse = { + userId: string; + sessionId: string; + factors: Factors | undefined; +}; +export async function registerUser(command: RegisterUserCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const addResponse = await addHumanUser({ + serviceUrl, + email: command.email, + firstName: command.firstName, + lastName: command.lastName, + password: command.password ? command.password : undefined, + organization: command.organization, + }); + + if (!addResponse) { + return { error: "Could not create user" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + let checkPayload: any = { + user: { search: { case: "userId", value: addResponse.userId } }, + }; + + if (command.password) { + checkPayload = { + ...checkPayload, + password: { password: command.password }, + } as ChecksJson; + } + + const checks = create(ChecksSchema, checkPayload); + + const session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + lifetime: command.password + ? loginSettings?.passwordCheckLifetime + : undefined, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + if (!command.password) { + const params = new URLSearchParams({ + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }); + + if (command.requestId) { + params.append("requestId", command.requestId); + } + + return { redirect: "/passkey/set?" + params }; + } else { + const userResponse = await getUserByID({ + serviceUrl, + userId: session?.factors?.user?.id, + }); + + if (!userResponse.user) { + return { error: "User not found in the system" }; + } + + const humanUser = + userResponse.user.type.case === "human" + ? userResponse.user.type.value + : undefined; + + const emailVerificationCheck = checkEmailVerification( + session, + humanUser, + session.factors.user.organizationId, + command.requestId, + ); + + if (emailVerificationCheck?.redirect) { + return emailVerificationCheck; + } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; + } +} + +type RegisterUserAndLinkToIDPommand = { + email: string; + firstName: string; + lastName: string; + organization: string; + requestId?: string; + idpIntent: { + idpIntentId: string; + idpIntentToken: string; + }; + idpUserId: string; + idpId: string; + idpUserName: string; +}; + +export type registerUserAndLinkToIDPResponse = { + userId: string; + sessionId: string; + factors: Factors | undefined; +}; +export async function registerUserAndLinkToIDP( + command: RegisterUserAndLinkToIDPommand, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const addResponse = await addHumanUser({ + serviceUrl, + email: command.email, + firstName: command.firstName, + lastName: command.lastName, + organization: command.organization, + }); + + if (!addResponse) { + return { error: "Could not create user" }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: command.organization, + }); + + const idpLink = await addIDPLink({ + serviceUrl, + idp: { + id: command.idpId, + userId: command.idpUserId, + userName: command.idpUserName, + }, + userId: addResponse.userId, + }); + + if (!idpLink) { + return { error: "Could not link IDP to user" }; + } + + const session = await createSessionForIdpAndUpdateCookie({ + requestId: command.requestId, + userId: addResponse.userId, // the user we just created + idpIntent: command.idpIntent, + lifetime: loginSettings?.externalLoginCheckLifetime, + }); + + if (!session || !session.factors?.user) { + return { error: "Could not create session" }; + } + + const url = await getNextUrl( + command.requestId && session.id + ? { + sessionId: session.id, + requestId: command.requestId, + organization: session.factors.user.organizationId, + } + : { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} diff --git a/login/apps/login/src/lib/server/session.ts b/login/apps/login/src/lib/server/session.ts new file mode 100644 index 0000000000..2aceb3a1d0 --- /dev/null +++ b/login/apps/login/src/lib/server/session.ts @@ -0,0 +1,221 @@ +"use server"; + +import { setSessionAndUpdateCookie } from "@/lib/server/cookie"; +import { + deleteSession, + getLoginSettings, + getSecuritySettings, + humanMFAInitSkipped, + listAuthenticationMethodTypes, +} from "@/lib/zitadel"; +import { Duration } from "@zitadel/client"; +import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, + removeSessionFromCookie, +} from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; + +export async function skipMFAAndContinueWithNextUrl({ + userId, + requestId, + loginName, + sessionId, + organization, +}: { + userId: string; + loginName?: string; + sessionId?: string; + requestId?: string; + organization?: string; +}) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: organization, + }); + + await humanMFAInitSkipped({ serviceUrl, userId }); + + const url = + requestId && sessionId + ? await getNextUrl( + { + sessionId: sessionId, + requestId: requestId, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : loginName + ? await getNextUrl( + { + loginName: loginName, + organization: organization, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return { redirect: url }; + } +} + +export async function continueWithSession({ + requestId, + ...session +}: Session & { requestId?: string }) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors?.user?.organizationId, + }); + + const url = + requestId && session.id && session.factors?.user + ? await getNextUrl( + { + sessionId: session.id, + requestId: requestId, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : session.factors?.user + ? await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors.user.organizationId, + }, + loginSettings?.defaultRedirectUri, + ) + : null; + if (url) { + return { redirect: url }; + } +} + +export type UpdateSessionCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + checks?: Checks; + requestId?: string; + challenges?: RequestChallenges; + lifetime?: Duration; +}; + +export async function updateSession(options: UpdateSessionCommand) { + let { loginName, sessionId, organization, checks, requestId, challenges } = + options; + const recentSession = sessionId + ? await getSessionCookieById({ sessionId }) + : loginName + ? await getSessionCookieByLoginName({ loginName, organization }) + : await getMostRecentSessionCookie(); + + if (!recentSession) { + return { + error: "Could not find session", + }; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "Could not get host" }; + } + + if ( + host && + challenges && + challenges.webAuthN && + !challenges.webAuthN.domain + ) { + const [hostname, port] = host.split(":"); + + challenges.webAuthN.domain = hostname; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization, + }); + + const lifetime = checks?.webAuthN + ? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey + : checks?.otpEmail || checks?.otpSms + ? loginSettings?.secondFactorCheckLifetime + : undefined; + + const session = await setSessionAndUpdateCookie( + recentSession, + checks, + challenges, + requestId, + lifetime, + ); + + if (!session) { + return { error: "Could not update session" }; + } + + // if password, check if user has MFA methods + let authMethods; + if (checks && checks.password && session.factors?.user?.id) { + const response = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + if (response.authMethodTypes && response.authMethodTypes.length) { + authMethods = response.authMethodTypes; + } + } + + return { + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + authMethods, + }; +} + +type ClearSessionOptions = { + sessionId: string; +}; + +export async function clearSession(options: ClearSessionOptions) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { sessionId } = options; + + const sessionCookie = await getSessionCookieById({ sessionId }); + + const deleteResponse = await deleteSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const securitySettings = await getSecuritySettings({ serviceUrl }); + const sameSite = securitySettings?.embeddedIframe?.enabled ? "none" : true; + + if (!deleteResponse) { + throw new Error("Could not delete session"); + } + + return removeSessionFromCookie({ session: sessionCookie, sameSite }); +} diff --git a/login/apps/login/src/lib/server/u2f.ts b/login/apps/login/src/lib/server/u2f.ts new file mode 100644 index 0000000000..3fe5194336 --- /dev/null +++ b/login/apps/login/src/lib/server/u2f.ts @@ -0,0 +1,103 @@ +"use server"; + +import { getSession, registerU2F, verifyU2FRegistration } from "@/lib/zitadel"; +import { create } from "@zitadel/client"; +import { VerifyU2FRegistrationRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { userAgent } from "next/server"; +import { getSessionCookieById } from "../cookies"; +import { getServiceUrlFromHeaders } from "../service-url"; + +type RegisterU2FCommand = { + sessionId: string; +}; + +type VerifyU2FCommand = { + u2fId: string; + passkeyName?: string; + publicKeyCredential: any; + sessionId: string; +}; + +export async function addU2F(command: RegisterU2FCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + + if (!sessionCookie) { + return { error: "Could not get session" }; + } + + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const [hostname, port] = host.split(":"); + + if (!hostname) { + throw new Error("Could not get hostname"); + } + + const userId = session?.session?.factors?.user?.id; + + if (!session || !userId) { + return { error: "Could not get session" }; + } + + return registerU2F({ serviceUrl, userId, domain: hostname }); +} + +export async function verifyU2F(command: VerifyU2FCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host || typeof host !== "string") { + throw new Error("No host found"); + } + + let passkeyName = command.passkeyName; + if (!!!passkeyName) { + const headersList = await headers(); + const userAgentStructure = { headers: headersList }; + const { browser, device, os } = userAgent(userAgentStructure); + + passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ + device.vendor || device.model ? ", " : "" + }${os.name}${os.name ? ", " : ""}${browser.name}`; + } + const sessionCookie = await getSessionCookieById({ + sessionId: command.sessionId, + }); + + const session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }); + + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + return { error: "Could not get session" }; + } + + const request = create(VerifyU2FRegistrationRequestSchema, { + u2fId: command.u2fId, + publicKeyCredential: command.publicKeyCredential, + tokenName: passkeyName, + userId, + }); + + return verifyU2FRegistration({ serviceUrl, request }); +} diff --git a/login/apps/login/src/lib/server/verify.ts b/login/apps/login/src/lib/server/verify.ts new file mode 100644 index 0000000000..cf60f739b3 --- /dev/null +++ b/login/apps/login/src/lib/server/verify.ts @@ -0,0 +1,329 @@ +"use server"; + +import { + createInviteCode, + getLoginSettings, + getSession, + getUserByID, + listAuthenticationMethodTypes, + verifyEmail, + verifyInviteCode, + verifyTOTPRegistration, + sendEmailCode as zitadelSendEmailCode, +} from "@/lib/zitadel"; +import crypto from "crypto"; + +import { create } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { ChecksSchema } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { cookies, headers } from "next/headers"; +import { getNextUrl } from "../client"; +import { getSessionCookieByLoginName } from "../cookies"; +import { getOrSetFingerprintId } from "../fingerprint"; +import { getServiceUrlFromHeaders } from "../service-url"; +import { loadMostRecentSession } from "../session"; +import { checkMFAFactors } from "../verify-helper"; +import { createSessionAndUpdateCookie } from "./cookie"; + +export async function verifyTOTP( + code: string, + loginName?: string, + organization?: string, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return loadMostRecentSession({ + serviceUrl, + sessionParams: { + loginName, + organization, + }, + }).then((session) => { + if (session?.factors?.user?.id) { + return verifyTOTPRegistration({ + serviceUrl, + code, + userId: session.factors.user.id, + }); + } else { + throw Error("No user id found in session."); + } + }); +} + +type VerifyUserByEmailCommand = { + userId: string; + loginName?: string; // to determine already existing session + organization?: string; + code: string; + isInvite: boolean; + requestId?: string; +}; + +export async function sendVerification(command: VerifyUserByEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const verifyResponse = command.isInvite + ? await verifyInviteCode({ + serviceUrl, + userId: command.userId, + verificationCode: command.code, + }).catch((error) => { + console.warn(error); + return { error: "Could not verify invite" }; + }) + : await verifyEmail({ + serviceUrl, + userId: command.userId, + verificationCode: command.code, + }).catch((error) => { + console.warn(error); + return { error: "Could not verify email" }; + }); + + if ("error" in verifyResponse) { + return verifyResponse; + } + + if (!verifyResponse) { + return { error: "Could not verify" }; + } + + let session: Session | undefined; + const userResponse = await getUserByID({ + serviceUrl, + userId: command.userId, + }); + + if (!userResponse || !userResponse.user) { + return { error: "Could not load user" }; + } + + const user = userResponse.user; + + const sessionCookie = await getSessionCookieByLoginName({ + loginName: + "loginName" in command ? command.loginName : user.preferredLoginName, + organization: command.organization, + }).catch((error) => { + console.warn("Ignored error:", error); // checked later + }); + + if (sessionCookie) { + session = await getSession({ + serviceUrl, + sessionId: sessionCookie.id, + sessionToken: sessionCookie.token, + }).then((response) => { + if (response?.session) { + return response.session; + } + }); + } + + // load auth methods for user + const authMethodResponse = await listAuthenticationMethodTypes({ + serviceUrl, + userId: user.userId, + }); + + if (!authMethodResponse || !authMethodResponse.authMethodTypes) { + return { error: "Could not load possible authenticators" }; + } + + // if no authmethods are found on the user, redirect to set one up + if ( + authMethodResponse && + authMethodResponse.authMethodTypes && + authMethodResponse.authMethodTypes.length == 0 + ) { + if (!sessionCookie) { + const checks = create(ChecksSchema, { + user: { + search: { + case: "loginName", + value: userResponse.user.preferredLoginName, + }, + }, + }); + + session = await createSessionAndUpdateCookie({ + checks, + requestId: command.requestId, + }); + } + + if (!session) { + return { error: "Could not create session" }; + } + + const params = new URLSearchParams({ + sessionId: session.id, + }); + + if (session.factors?.user?.loginName) { + params.set("loginName", session.factors?.user?.loginName); + } + + // set hash of userId and userAgentId to prevent attacks, checks are done for users with invalid sessions and invalid userAgentId + const cookiesList = await cookies(); + const userAgentId = await getOrSetFingerprintId(); + + const verificationCheck = crypto + .createHash("sha256") + .update(`${user.userId}:${userAgentId}`) + .digest("hex"); + + await cookiesList.set({ + name: "verificationCheck", + value: verificationCheck, + httpOnly: true, + path: "/", + maxAge: 300, // 5 minutes + }); + + return { redirect: `/authenticator/set?${params}` }; + } + + // if no session found only show success page, + // if user is invited, recreate invite flow to not depend on session + if (!session?.factors?.user?.id) { + const verifySuccessParams = new URLSearchParams({}); + + if (command.userId) { + verifySuccessParams.set("userId", command.userId); + } + + if ( + ("loginName" in command && command.loginName) || + user.preferredLoginName + ) { + verifySuccessParams.set( + "loginName", + "loginName" in command && command.loginName + ? command.loginName + : user.preferredLoginName, + ); + } + if (command.requestId) { + verifySuccessParams.set("requestId", command.requestId); + } + if (command.organization) { + verifySuccessParams.set("organization", command.organization); + } + + return { redirect: `/verify/success?${verifySuccessParams}` }; + } + + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: user.details?.resourceOwner, + }); + + // redirect to mfa factor if user has one, or redirect to set one up + const mfaFactorCheck = await checkMFAFactors( + serviceUrl, + session, + loginSettings, + authMethodResponse.authMethodTypes, + command.organization, + command.requestId, + ); + + if (mfaFactorCheck?.redirect) { + return mfaFactorCheck; + } + + // login user if no additional steps are required + if (command.requestId && session.id) { + const nextUrl = await getNextUrl( + { + sessionId: session.id, + requestId: command.requestId, + organization: + command.organization ?? session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: nextUrl }; + } + + const url = await getNextUrl( + { + loginName: session.factors.user.loginName, + organization: session.factors?.user?.organizationId, + }, + loginSettings?.defaultRedirectUri, + ); + + return { redirect: url }; +} + +type resendVerifyEmailCommand = { + userId: string; + isInvite: boolean; + requestId?: string; +}; + +export async function resendVerification(command: resendVerifyEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const host = _headers.get("host"); + + if (!host) { + return { error: "No host found" }; + } + + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + + return command.isInvite + ? createInviteCode({ + serviceUrl, + userId: command.userId, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }).catch((error) => { + if (error.code === 9) { + return { error: "User is already verified!" }; + } + return { error: "Could not resend invite" }; + }) + : zitadelSendEmailCode({ + userId: command.userId, + serviceUrl, + urlTemplate: + `${host.includes("localhost") ? "http://" : "https://"}${host}${basePath}/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}` + + (command.requestId ? `&requestId=${command.requestId}` : ""), + }); +} + +type SendEmailCommand = { + userId: string; + urlTemplate: string; +}; + +export async function sendEmailCode(command: SendEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return zitadelSendEmailCode({ + serviceUrl, + userId: command.userId, + urlTemplate: command.urlTemplate, + }); +} + +export async function sendInviteEmailCode(command: SendEmailCommand) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return createInviteCode({ + serviceUrl, + userId: command.userId, + urlTemplate: command.urlTemplate, + }); +} diff --git a/login/apps/login/src/lib/service-url.ts b/login/apps/login/src/lib/service-url.ts new file mode 100644 index 0000000000..e74ee1f333 --- /dev/null +++ b/login/apps/login/src/lib/service-url.ts @@ -0,0 +1,58 @@ +import { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers"; +import { NextRequest } from "next/server"; + +/** + * Extracts the service url and region from the headers if used in a multitenant context (host, x-zitadel-forward-host header) + * or falls back to the ZITADEL_API_URL for a self hosting deployment + * or falls back to the host header for a self hosting deployment using custom domains + * @param headers + * @returns the service url and region from the headers + * @throws if the service url could not be determined + * + */ +export function getServiceUrlFromHeaders(headers: ReadonlyHeaders): { + serviceUrl: string; +} { + let instanceUrl; + + const forwardedHost = headers.get("x-zitadel-forward-host"); + // use the forwarded host if available (multitenant), otherwise fall back to the host of the deployment itself + if (forwardedHost) { + instanceUrl = forwardedHost; + instanceUrl = instanceUrl.startsWith("http://") + ? instanceUrl + : `https://${instanceUrl}`; + } else if (process.env.ZITADEL_API_URL) { + instanceUrl = process.env.ZITADEL_API_URL; + } else { + const host = headers.get("host"); + + if (host) { + const [hostname, port] = host.split(":"); + if (hostname !== "localhost") { + instanceUrl = host.startsWith("http") ? host : `https://${host}`; + } + } + } + + if (!instanceUrl) { + throw new Error("Service URL could not be determined"); + } + + return { + serviceUrl: instanceUrl, + }; +} + +export function constructUrl(request: NextRequest, path: string) { + const forwardedProto = request.headers.get("x-forwarded-proto") + ? `${request.headers.get("x-forwarded-proto")}:` + : request.nextUrl.protocol; + + const forwardedHost = + request.headers.get("x-zitadel-forward-host") ?? + request.headers.get("x-forwarded-host") ?? + request.headers.get("host"); + const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ""; + return new URL(`${basePath}${path}`, `${forwardedProto}//${forwardedHost}`); +} diff --git a/login/apps/login/src/lib/service.ts b/login/apps/login/src/lib/service.ts new file mode 100644 index 0000000000..f7e81cc9d6 --- /dev/null +++ b/login/apps/login/src/lib/service.ts @@ -0,0 +1,49 @@ +import { createClientFor } from "@zitadel/client"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; +import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; +import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { systemAPIToken } from "./api"; +import { createServerTransport } from "./zitadel"; + +type ServiceClass = + | typeof IdentityProviderService + | typeof UserService + | typeof OrganizationService + | typeof SessionService + | typeof OIDCService + | typeof SettingsService + | typeof SAMLService; + +export async function createServiceForHost( + service: T, + serviceUrl: string, +) { + let token; + + // if we are running in a multitenancy context, use the system user token + if ( + process.env.AUDIENCE && + process.env.SYSTEM_USER_ID && + process.env.SYSTEM_USER_PRIVATE_KEY + ) { + token = await systemAPIToken(); + } else if (process.env.ZITADEL_SERVICE_USER_TOKEN) { + token = process.env.ZITADEL_SERVICE_USER_TOKEN; + } + + if (!serviceUrl) { + throw new Error("No instance url found"); + } + + if (!token) { + throw new Error("No token found"); + } + + const transport = createServerTransport(token, serviceUrl); + + return createClientFor(service)(transport); +} diff --git a/login/apps/login/src/lib/session.ts b/login/apps/login/src/lib/session.ts new file mode 100644 index 0000000000..9698c4c4ba --- /dev/null +++ b/login/apps/login/src/lib/session.ts @@ -0,0 +1,194 @@ +import { timestampDate } from "@zitadel/client"; +import { AuthRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; +import { SAMLRequest } from "@zitadel/proto/zitadel/saml/v2/authorization_pb"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { GetSessionResponse } from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { getMostRecentCookieWithLoginname } from "./cookies"; +import { + getLoginSettings, + getSession, + listAuthenticationMethodTypes, +} from "./zitadel"; + +type LoadMostRecentSessionParams = { + serviceUrl: string; + + sessionParams: { + loginName?: string; + organization?: string; + }; +}; + +export async function loadMostRecentSession({ + serviceUrl, + sessionParams, +}: LoadMostRecentSessionParams): Promise { + const recent = await getMostRecentCookieWithLoginname({ + loginName: sessionParams.loginName, + organization: sessionParams.organization, + }); + + return getSession({ + serviceUrl, + sessionId: recent.id, + sessionToken: recent.token, + }).then((resp: GetSessionResponse) => resp.session); +} + +/** + * mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.) + * to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId); + **/ +export async function isSessionValid({ + serviceUrl, + session, +}: { + serviceUrl: string; + session: Session; +}): Promise { + // session can't be checked without user + if (!session.factors?.user) { + console.warn("Session has no user"); + return false; + } + + let mfaValid = true; + + const authMethodTypes = await listAuthenticationMethodTypes({ + serviceUrl, + userId: session.factors.user.id, + }); + + const authMethods = authMethodTypes.authMethodTypes; + if (authMethods && authMethods.includes(AuthenticationMethodType.TOTP)) { + mfaValid = !!session.factors.totp?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid totpEmail factor", + session.factors.totp?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.OTP_EMAIL) + ) { + mfaValid = !!session.factors.otpEmail?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid otpEmail factor", + session.factors.otpEmail?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.OTP_SMS) + ) { + mfaValid = !!session.factors.otpSms?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid otpSms factor", + session.factors.otpSms?.verifiedAt, + ); + } + } else if ( + authMethods && + authMethods.includes(AuthenticationMethodType.U2F) + ) { + mfaValid = !!session.factors.webAuthN?.verifiedAt; + if (!mfaValid) { + console.warn( + "Session has no valid u2f factor", + session.factors.webAuthN?.verifiedAt, + ); + } + } else { + // only check settings if no auth methods are available, as this would require a setup + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: session.factors?.user?.organizationId, + }); + if (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) { + const otpEmail = session.factors.otpEmail?.verifiedAt; + const otpSms = session.factors.otpSms?.verifiedAt; + const totp = session.factors.totp?.verifiedAt; + const webAuthN = session.factors.webAuthN?.verifiedAt; + const idp = session.factors.intent?.verifiedAt; // TODO: forceMFA should not consider this as valid factor + + // must have one single check + mfaValid = !!(otpEmail || otpSms || totp || webAuthN || idp); + if (!mfaValid) { + console.warn("Session has no valid multifactor", session.factors); + } + } else { + mfaValid = true; + } + } + + const validPassword = session?.factors?.password?.verifiedAt; + const validPasskey = session?.factors?.webAuthN?.verifiedAt; + const validIDP = session?.factors?.intent?.verifiedAt; + + const stillValid = session.expirationDate + ? timestampDate(session.expirationDate).getTime() > new Date().getTime() + : true; + + if (!stillValid) { + console.warn( + "Session is expired", + session.expirationDate + ? timestampDate(session.expirationDate).toDateString() + : "no expiration date", + ); + } + + const validChecks = !!(validPassword || validPasskey || validIDP); + + return stillValid && validChecks && mfaValid; +} + +export async function findValidSession({ + serviceUrl, + sessions, + authRequest, + samlRequest, +}: { + serviceUrl: string; + sessions: Session[]; + authRequest?: AuthRequest; + samlRequest?: SAMLRequest; +}): Promise { + const sessionsWithHint = sessions.filter((s) => { + if (authRequest && authRequest.hintUserId) { + return s.factors?.user?.id === authRequest.hintUserId; + } + if (authRequest && authRequest.loginHint) { + return s.factors?.user?.loginName === authRequest.loginHint; + } + if (samlRequest) { + // TODO: do whatever + return true; + } + return true; + }); + + if (sessionsWithHint.length === 0) { + return undefined; + } + + // sort by change date descending + sessionsWithHint.sort((a, b) => { + const dateA = a.changeDate ? timestampDate(a.changeDate).getTime() : 0; + const dateB = b.changeDate ? timestampDate(b.changeDate).getTime() : 0; + return dateB - dateA; + }); + + // return the first valid session according to settings + for (const session of sessionsWithHint) { + if (await isSessionValid({ serviceUrl, session })) { + return session; + } + } + + return undefined; +} diff --git a/login/apps/login/src/lib/verify-helper.ts b/login/apps/login/src/lib/verify-helper.ts new file mode 100644 index 0000000000..dbd9b2796b --- /dev/null +++ b/login/apps/login/src/lib/verify-helper.ts @@ -0,0 +1,289 @@ +import { timestampDate } from "@zitadel/client"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb"; +import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import crypto from "crypto"; +import moment from "moment"; +import { cookies } from "next/headers"; +import { getFingerprintIdCookie } from "./fingerprint"; +import { getUserByID } from "./zitadel"; + +export function checkPasswordChangeRequired( + expirySettings: PasswordExpirySettings | undefined, + session: Session, + humanUser: HumanUser | undefined, + organization?: string, + requestId?: string, +) { + let isOutdated = false; + if (expirySettings?.maxAgeDays && humanUser?.passwordChanged) { + const maxAgeDays = Number(expirySettings.maxAgeDays); // Convert bigint to number + const passwordChangedDate = moment( + timestampDate(humanUser.passwordChanged), + ); + const outdatedPassword = passwordChangedDate.add(maxAgeDays, "days"); + isOutdated = moment().isAfter(outdatedPassword); + } + + if (humanUser?.passwordChangeRequired || isOutdated) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + session.factors?.user?.organizationId as string, + ); + } + + if (requestId) { + params.append("requestId", requestId); + } + + return { redirect: "/password/change?" + params }; + } +} + +export function checkEmailVerified( + session: Session, + humanUser?: HumanUser, + organization?: string, + requestId?: string, +) { + if (!humanUser?.email?.isVerified) { + const paramsVerify = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + userId: session.factors?.user?.id as string, // verify needs user id + send: "true", // we request a new email code once the page is loaded + }); + + if (organization || session.factors?.user?.organizationId) { + paramsVerify.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + if (requestId) { + paramsVerify.append("requestId", requestId); + } + + return { redirect: "/verify?" + paramsVerify }; + } +} + +export function checkEmailVerification( + session: Session, + humanUser?: HumanUser, + organization?: string, + requestId?: string, +) { + if ( + !humanUser?.email?.isVerified && + process.env.EMAIL_VERIFICATION === "true" + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + send: "true", // set this to true as we dont expect old email codes to be valid anymore + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/verify?` + params }; + } +} + +export async function checkMFAFactors( + serviceUrl: string, + session: Session, + loginSettings: LoginSettings | undefined, + authMethods: AuthenticationMethodType[], + organization?: string, + requestId?: string, +) { + const availableMultiFactors = authMethods?.filter( + (m: AuthenticationMethodType) => + m !== AuthenticationMethodType.PASSWORD && + m !== AuthenticationMethodType.PASSKEY, + ); + + const hasAuthenticatedWithPasskey = + session.factors?.webAuthN?.verifiedAt && + session.factors?.webAuthN?.userVerified; + + // escape further checks if user has authenticated with passkey + if (hasAuthenticatedWithPasskey) { + return; + } + + // if user has not authenticated with passkey and has only one additional mfa factor, redirect to that + if (availableMultiFactors?.length == 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + const factor = availableMultiFactors[0]; + // if passwordless is other method, but user selected password as alternative, perform a login + if (factor === AuthenticationMethodType.TOTP) { + return { redirect: `/otp/time-based?` + params }; + } else if (factor === AuthenticationMethodType.OTP_SMS) { + return { redirect: `/otp/sms?` + params }; + } else if (factor === AuthenticationMethodType.OTP_EMAIL) { + return { redirect: `/otp/email?` + params }; + } else if (factor === AuthenticationMethodType.U2F) { + return { redirect: `/u2f?` + params }; + } + } else if (availableMultiFactors?.length > 1) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + return { redirect: `/mfa?` + params }; + } else if ( + (loginSettings?.forceMfa || loginSettings?.forceMfaLocalOnly) && + !availableMultiFactors.length + ) { + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "true", // this defines if the mfa is forced in the settings + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + // TODO: provide a way to setup passkeys on mfa page? + return { redirect: `/mfa/set?` + params }; + } else if ( + loginSettings?.mfaInitSkipLifetime && + (loginSettings.mfaInitSkipLifetime.nanos > 0 || + loginSettings.mfaInitSkipLifetime.seconds > 0) && + !availableMultiFactors.length && + session?.factors?.user?.id + ) { + const userResponse = await getUserByID({ + serviceUrl, + userId: session.factors?.user?.id, + }); + + const humanUser = + userResponse?.user?.type.case === "human" + ? userResponse?.user.type.value + : undefined; + + if (humanUser?.mfaInitSkipped) { + const mfaInitSkippedTimestamp = timestampDate(humanUser.mfaInitSkipped); + + const mfaInitSkipLifetimeMillis = + Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 + + loginSettings.mfaInitSkipLifetime.nanos / 1000000; + const currentTime = Date.now(); + const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime(); + const timeDifference = currentTime - mfaInitSkippedTime; + + if (!(timeDifference > mfaInitSkipLifetimeMillis)) { + // if the time difference is smaller than the lifetime, skip the mfa setup + return; + } + } + + // the user has never skipped the mfa init but we have a setting so we redirect + + const params = new URLSearchParams({ + loginName: session.factors?.user?.loginName as string, + force: "false", // this defines if the mfa is not forced in the settings and can be skipped + checkAfter: "true", // this defines if the check is directly made after the setup + }); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization || session.factors?.user?.organizationId) { + params.append( + "organization", + organization ?? (session.factors?.user?.organizationId as string), + ); + } + + // TODO: provide a way to setup passkeys on mfa page? + return { redirect: `/mfa/set?` + params }; + } +} + +export async function checkUserVerification(userId: string): Promise { + // check if a verification was done earlier + const cookiesList = await cookies(); + + // only read cookie to prevent issues on page.tsx + const fingerPrintCookie = await getFingerprintIdCookie(); + + if (!fingerPrintCookie || !fingerPrintCookie.value) { + return false; + } + + const verificationCheck = crypto + .createHash("sha256") + .update(`${userId}:${fingerPrintCookie.value}`) + .digest("hex"); + + const cookieValue = await cookiesList.get("verificationCheck")?.value; + + if (!cookieValue) { + console.warn( + "User verification check cookie not found. User verification check failed.", + ); + return false; + } + + if (cookieValue !== verificationCheck) { + console.warn( + `User verification check failed. Expected ${verificationCheck} but got ${cookieValue}`, + ); + return false; + } + + return true; +} diff --git a/login/apps/login/src/lib/zitadel.ts b/login/apps/login/src/lib/zitadel.ts new file mode 100644 index 0000000000..483d4e4ac9 --- /dev/null +++ b/login/apps/login/src/lib/zitadel.ts @@ -0,0 +1,1525 @@ +import { Client, create, Duration } from "@zitadel/client"; +import { createServerTransport as libCreateServerTransport } from "@zitadel/client/node"; +import { makeReqCtx } from "@zitadel/client/v2"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb"; +import { + OrganizationSchema, + TextQueryMethod, +} from "@zitadel/proto/zitadel/object/v2/object_pb"; +import { + CreateCallbackRequest, + OIDCService, +} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb"; +import { + CreateResponseRequest, + SAMLService, +} from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; +import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_pb"; +import { + Checks, + SessionService, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb"; +import { SendEmailVerificationCodeSchema } from "@zitadel/proto/zitadel/user/v2/email_pb"; +import type { + FormData, + RedirectURLsJson, +} from "@zitadel/proto/zitadel/user/v2/idp_pb"; +import { + NotificationType, + SendPasswordResetLinkSchema, +} from "@zitadel/proto/zitadel/user/v2/password_pb"; +import { + SearchQuery, + SearchQuerySchema, +} from "@zitadel/proto/zitadel/user/v2/query_pb"; +import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb"; +import { + AddHumanUserRequest, + AddHumanUserRequestSchema, + ResendEmailCodeRequest, + ResendEmailCodeRequestSchema, + SendEmailCodeRequestSchema, + SetPasswordRequest, + SetPasswordRequestSchema, + UpdateHumanUserRequest, + UserService, + VerifyPasskeyRegistrationRequest, + VerifyU2FRegistrationRequest, +} from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { unstable_cacheLife as cacheLife } from "next/cache"; +import { getUserAgent } from "./fingerprint"; +import { createServiceForHost } from "./service"; + +const useCache = process.env.DEBUG !== "true"; + +async function cacheWrapper(callback: Promise) { + "use cache"; + cacheLife("hours"); + + return callback; +} + +export async function getHostedLoginTranslation({ + serviceUrl, + organization, + locale, +}: { + serviceUrl: string; + organization?: string; + locale?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getHostedLoginTranslation( + { + level: organization + ? { + case: "organizationId", + value: organization, + } + : { + case: "instance", + value: true, + }, + locale: locale, + }, + {}, + ) + .then((resp) => { + return resp.translations ? resp.translations : undefined; + }); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getBrandingSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getBrandingSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLoginSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLoginSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getSecuritySettings({ + serviceUrl, +}: { + serviceUrl: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getSecuritySettings({}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLockoutSettings({ + serviceUrl, + orgId, +}: { + serviceUrl: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLockoutSettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getPasswordExpirySettings({ + serviceUrl, + orgId, +}: { + serviceUrl: string; + orgId?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getPasswordExpirySettings({ ctx: makeReqCtx(orgId) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function listIDPLinks({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listIDPLinks({ userId }, {}); +} + +export async function addOTPEmail({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addOTPEmail({ userId }, {}); +} + +export async function addOTPSMS({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addOTPSMS({ userId }, {}); +} + +export async function registerTOTP({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerTOTP({ userId }, {}); +} + +export async function getGeneralSettings({ + serviceUrl, +}: { + serviceUrl: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getGeneralSettings({}, {}) + .then((resp) => resp.supportedLanguages); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getLegalAndSupportSettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getLegalAndSupportSettings({ ctx: makeReqCtx(organization) }, {}) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function getPasswordComplexitySettings({ + serviceUrl, + organization, +}: { + serviceUrl: string; + organization?: string; +}) { + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + const callback = settingsService + .getPasswordComplexitySettings({ ctx: makeReqCtx(organization) }) + .then((resp) => (resp.settings ? resp.settings : undefined)); + + return useCache ? cacheWrapper(callback) : callback; +} + +export async function createSessionFromChecks({ + serviceUrl, + checks, + lifetime, +}: { + serviceUrl: string; + checks: Checks; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + const userAgent = await getUserAgent(); + + return sessionService.createSession({ checks, lifetime, userAgent }, {}); +} + +export async function createSessionForUserIdAndIdpIntent({ + serviceUrl, + userId, + idpIntent, + lifetime, +}: { + serviceUrl: string; + userId: string; + idpIntent: { + idpIntentId?: string | undefined; + idpIntentToken?: string | undefined; + }; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + const userAgent = await getUserAgent(); + + return sessionService.createSession({ + checks: { + user: { + search: { + case: "userId", + value: userId, + }, + }, + idpIntent, + }, + lifetime, + userAgent, + }); +} + +export async function setSession({ + serviceUrl, + sessionId, + sessionToken, + challenges, + checks, + lifetime, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; + challenges: RequestChallenges | undefined; + checks?: Checks; + lifetime?: Duration; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.setSession( + { + sessionId, + sessionToken, + challenges, + checks: checks ? checks : {}, + metadata: {}, + lifetime, + }, + {}, + ); +} + +export async function getSession({ + serviceUrl, + sessionId, + sessionToken, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.getSession({ sessionId, sessionToken }, {}); +} + +export async function deleteSession({ + serviceUrl, + sessionId, + sessionToken, +}: { + serviceUrl: string; + sessionId: string; + sessionToken: string; +}) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.deleteSession({ sessionId, sessionToken }, {}); +} + +type ListSessionsCommand = { + serviceUrl: string; + ids: string[]; +}; + +export async function listSessions({ serviceUrl, ids }: ListSessionsCommand) { + const sessionService: Client = + await createServiceForHost(SessionService, serviceUrl); + + return sessionService.listSessions( + { + queries: [ + { + query: { + case: "idsQuery", + value: { ids }, + }, + }, + ], + }, + {}, + ); +} + +export type AddHumanUserData = { + serviceUrl: string; + firstName: string; + lastName: string; + email: string; + password?: string; + organization: string; +}; + +export async function addHumanUser({ + serviceUrl, + email, + firstName, + lastName, + password, + organization, +}: AddHumanUserData) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + let addHumanUserRequest: AddHumanUserRequest = create( + AddHumanUserRequestSchema, + { + email: { + email, + verification: { + case: "isVerified", + value: false, + }, + }, + username: email, + profile: { givenName: firstName, familyName: lastName }, + passwordType: password + ? { case: "password", value: { password } } + : undefined, + }, + ); + + if (organization) { + const organizationSchema = create(OrganizationSchema, { + org: { case: "orgId", value: organization }, + }); + + addHumanUserRequest = { + ...addHumanUserRequest, + organization: organizationSchema, + }; + } + + return userService.addHumanUser(addHumanUserRequest); +} + +export async function addHuman({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: AddHumanUserRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addHumanUser(request); +} + +export async function updateHuman({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: UpdateHumanUserRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.updateHumanUser(request); +} + +export async function verifyTOTPRegistration({ + serviceUrl, + code, + userId, +}: { + serviceUrl: string; + code: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyTOTPRegistration({ code, userId }, {}); +} + +export async function getUserByID({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.getUserByID({ userId }, {}); +} + +export async function humanMFAInitSkipped({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.humanMFAInitSkipped({ userId }, {}); +} + +export async function verifyInviteCode({ + serviceUrl, + userId, + verificationCode, +}: { + serviceUrl: string; + userId: string; + verificationCode: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyInviteCode({ userId, verificationCode }, {}); +} + +export async function sendEmailCode({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate: string; +}) { + let medium = create(SendEmailCodeRequestSchema, { userId }); + + medium = create(SendEmailCodeRequestSchema, { + ...medium, + verification: { + case: "sendCode", + value: create(SendEmailVerificationCodeSchema, { + urlTemplate, + }), + }, + }); + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.sendEmailCode(medium, {}); +} + +export async function createInviteCode({ + serviceUrl, + urlTemplate, + userId, +}: { + serviceUrl: string; + urlTemplate: string; + userId: string; +}) { + let medium = create(SendInviteCodeSchema, { + applicationName: "Typescript Login", + }); + + medium = { + ...medium, + urlTemplate, + }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.createInviteCode( + { + userId, + verification: { + case: "sendCode", + value: medium, + }, + }, + {}, + ); +} + +export type ListUsersCommand = { + serviceUrl: string; + loginName?: string; + userName?: string; + email?: string; + phone?: string; + organizationId?: string; +}; + +export async function listUsers({ + serviceUrl, + loginName, + userName, + phone, + email, + organizationId, +}: ListUsersCommand) { + const queries: SearchQuery[] = []; + + // either use loginName or userName, email, phone + if (loginName) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "loginNameQuery", + value: { + loginName, + method: TextQueryMethod.EQUALS, + }, + }, + }), + ); + } else if (userName || email || phone) { + const orQueries: SearchQuery[] = []; + + if (userName) { + const userNameQuery = create(SearchQuerySchema, { + query: { + case: "userNameQuery", + value: { + userName, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(userNameQuery); + } + + if (email) { + const emailQuery = create(SearchQuerySchema, { + query: { + case: "emailQuery", + value: { + emailAddress: email, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(emailQuery); + } + + if (phone) { + const phoneQuery = create(SearchQuerySchema, { + query: { + case: "phoneQuery", + value: { + number: phone, + method: TextQueryMethod.EQUALS, + }, + }, + }); + orQueries.push(phoneQuery); + } + + queries.push( + create(SearchQuerySchema, { + query: { + case: "orQuery", + value: { + queries: orQueries, + }, + }, + }), + ); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listUsers({ queries }); +} + +export type SearchUsersCommand = { + serviceUrl: string; + searchValue: string; + loginSettings: LoginSettings; + organizationId?: string; + suffix?: string; +}; + +const PhoneQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "phoneQuery", + value: { + number: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +const LoginNameQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "loginNameQuery", + value: { + loginName: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +const EmailQuery = (searchValue: string) => + create(SearchQuerySchema, { + query: { + case: "emailQuery", + value: { + emailAddress: searchValue, + method: TextQueryMethod.EQUALS, + }, + }, + }); + +/** + * this is a dedicated search function to search for users from the loginname page + * it searches users based on the loginName or userName and org suffix combination, and falls back to email and phone if no users are found + * */ +export async function searchUsers({ + serviceUrl, + searchValue, + loginSettings, + organizationId, + suffix, +}: SearchUsersCommand) { + const queries: SearchQuery[] = []; + + // if a suffix is provided, we search for the userName concatenated with the suffix + if (suffix) { + const searchValueWithSuffix = `${searchValue}@${suffix}`; + const loginNameQuery = LoginNameQuery(searchValueWithSuffix); + queries.push(loginNameQuery); + } else { + const loginNameQuery = LoginNameQuery(searchValue); + queries.push(loginNameQuery); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + const loginNameResult = await userService.listUsers({ queries }); + + if (!loginNameResult || !loginNameResult.details) { + return { error: "An error occurred." }; + } + + if (loginNameResult.result.length > 1) { + return { error: "Multiple users found" }; + } + + if (loginNameResult.result.length == 1) { + return loginNameResult; + } + + const emailAndPhoneQueries: SearchQuery[] = []; + if ( + loginSettings.disableLoginWithEmail && + loginSettings.disableLoginWithPhone + ) { + return { error: "User not found in the system" }; + } else if (loginSettings.disableLoginWithEmail && searchValue.length <= 20) { + const phoneQuery = PhoneQuery(searchValue); + emailAndPhoneQueries.push(phoneQuery); + } else if (loginSettings.disableLoginWithPhone) { + const emailQuery = EmailQuery(searchValue); + emailAndPhoneQueries.push(emailQuery); + } else { + const emailAndPhoneOrQueries: SearchQuery[] = []; + + const emailQuery = EmailQuery(searchValue); + emailAndPhoneOrQueries.push(emailQuery); + + let phoneQuery; + if (searchValue.length <= 20) { + phoneQuery = PhoneQuery(searchValue); + emailAndPhoneOrQueries.push(phoneQuery); + } + + emailAndPhoneQueries.push( + create(SearchQuerySchema, { + query: { + case: "orQuery", + value: { + queries: emailAndPhoneOrQueries, + }, + }, + }), + ); + } + + if (organizationId) { + queries.push( + create(SearchQuerySchema, { + query: { + case: "organizationIdQuery", + value: { + organizationId, + }, + }, + }), + ); + } + + const emailOrPhoneResult = await userService.listUsers({ + queries: emailAndPhoneQueries, + }); + + if (!emailOrPhoneResult || !emailOrPhoneResult.details) { + return { error: "An error occurred." }; + } + + if (emailOrPhoneResult.result.length > 1) { + return { error: "Multiple users found." }; + } + + if (emailOrPhoneResult.result.length == 1) { + return loginNameResult; + } + + return { error: "User not found in the system" }; +} + +export async function getDefaultOrg({ + serviceUrl, +}: { + serviceUrl: string; +}): Promise { + const orgService: Client = + await createServiceForHost(OrganizationService, serviceUrl); + + return orgService + .listOrganizations( + { + queries: [ + { + query: { + case: "defaultQuery", + value: {}, + }, + }, + ], + }, + {}, + ) + .then((resp) => (resp?.result && resp.result[0] ? resp.result[0] : null)); +} + +export async function getOrgsByDomain({ + serviceUrl, + domain, +}: { + serviceUrl: string; + domain: string; +}) { + const orgService: Client = + await createServiceForHost(OrganizationService, serviceUrl); + + return orgService.listOrganizations( + { + queries: [ + { + query: { + case: "domainQuery", + value: { domain, method: TextQueryMethod.EQUALS }, + }, + }, + ], + }, + {}, + ); +} + +export async function startIdentityProviderFlow({ + serviceUrl, + idpId, + urls, +}: { + serviceUrl: string; + idpId: string; + urls: RedirectURLsJson; +}): Promise { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService + .startIdentityProviderIntent({ + idpId, + content: { + case: "urls", + value: urls, + }, + }) + .then((resp) => { + if (resp.nextStep.case === "authUrl" && resp.nextStep.value) { + return resp.nextStep.value; + } else if (resp.nextStep.case === "formData" && resp.nextStep.value) { + const formData: FormData = resp.nextStep.value; + const redirectUrl = "/saml-post"; + + const params = new URLSearchParams({ url: formData.url }); + + Object.entries(formData.fields).forEach(([k, v]) => { + params.append(k, v); + }); + + return `${redirectUrl}?${params.toString()}`; + } else { + return null; + } + }); +} + +export async function startLDAPIdentityProviderFlow({ + serviceUrl, + idpId, + username, + password, +}: { + serviceUrl: string; + idpId: string; + username: string; + password: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.startIdentityProviderIntent({ + idpId, + content: { + case: "ldap", + value: { + username, + password, + }, + }, + }); +} + +export async function getAuthRequest({ + serviceUrl, + authRequestId, +}: { + serviceUrl: string; + authRequestId: string; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.getAuthRequest({ + authRequestId, + }); +} + +export async function getDeviceAuthorizationRequest({ + serviceUrl, + userCode, +}: { + serviceUrl: string; + userCode: string; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.getDeviceAuthorizationRequest({ + userCode, + }); +} + +export async function authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, +}: { + serviceUrl: string; + deviceAuthorizationId: string; + session?: { sessionId: string; sessionToken: string }; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.authorizeOrDenyDeviceAuthorization({ + deviceAuthorizationId, + decision: session + ? { + case: "session", + value: session, + } + : { + case: "deny", + value: {}, + }, + }); +} + +export async function createCallback({ + serviceUrl, + req, +}: { + serviceUrl: string; + req: CreateCallbackRequest; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.createCallback(req); +} + +export async function getSAMLRequest({ + serviceUrl, + samlRequestId, +}: { + serviceUrl: string; + samlRequestId: string; +}) { + const samlService = await createServiceForHost(SAMLService, serviceUrl); + + return samlService.getSAMLRequest({ + samlRequestId, + }); +} + +export async function createResponse({ + serviceUrl, + req, +}: { + serviceUrl: string; + req: CreateResponseRequest; +}) { + const samlService = await createServiceForHost(SAMLService, serviceUrl); + + return samlService.createResponse(req); +} + +export async function verifyEmail({ + serviceUrl, + userId, + verificationCode, +}: { + serviceUrl: string; + userId: string; + verificationCode: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyEmail( + { + userId, + verificationCode, + }, + {}, + ); +} + +export async function resendEmailCode({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate: string; +}) { + let request: ResendEmailCodeRequest = create(ResendEmailCodeRequestSchema, { + userId, + }); + + const medium = create(SendEmailVerificationCodeSchema, { + urlTemplate, + }); + + request = { ...request, verification: { case: "sendCode", value: medium } }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.resendEmailCode(request, {}); +} + +export async function retrieveIDPIntent({ + serviceUrl, + id, + token, +}: { + serviceUrl: string; + id: string; + token: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.retrieveIdentityProviderIntent( + { idpIntentId: id, idpIntentToken: token }, + {}, + ); +} + +export async function getIDPByID({ + serviceUrl, + id, +}: { + serviceUrl: string; + id: string; +}) { + const idpService: Client = + await createServiceForHost(IdentityProviderService, serviceUrl); + + return idpService.getIDPByID({ id }, {}).then((resp) => resp.idp); +} + +export async function addIDPLink({ + serviceUrl, + idp, + userId, +}: { + serviceUrl: string; + idp: { id: string; userId: string; userName: string }; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.addIDPLink( + { + idpLink: { + userId: idp.userId, + idpId: idp.id, + userName: idp.userName, + }, + userId, + }, + {}, + ); +} + +export async function passwordReset({ + serviceUrl, + userId, + urlTemplate, +}: { + serviceUrl: string; + userId: string; + urlTemplate?: string; +}) { + let medium = create(SendPasswordResetLinkSchema, { + notificationType: NotificationType.Email, + }); + + medium = { + ...medium, + urlTemplate, + }; + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.passwordReset( + { + userId, + medium: { + case: "sendLink", + value: medium, + }, + }, + {}, + ); +} + +export async function setUserPassword({ + serviceUrl, + userId, + password, + code, +}: { + serviceUrl: string; + userId: string; + password: string; + code?: string; +}) { + let payload = create(SetPasswordRequestSchema, { + userId, + newPassword: { + password, + }, + }); + + if (code) { + payload = { + ...payload, + verification: { + case: "verificationCode", + value: code, + }, + }; + } + + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.setPassword(payload, {}).catch((error) => { + // throw error if failed precondition (ex. User is not yet initialized) + if (error.code === 9 && error.message) { + return { error: error.message }; + } else { + throw error; + } + }); +} + +export async function setPassword({ + serviceUrl, + payload, +}: { + serviceUrl: string; + payload: SetPasswordRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.setPassword(payload, {}); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @returns the newly set email + */ +export async function createPasskeyRegistrationLink({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.createPasskeyRegistrationLink({ + userId, + medium: { + case: "returnCode", + value: {}, + }, + }); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @param domain the domain on which the factor is registered + * @returns the newly set email + */ +export async function registerU2F({ + serviceUrl, + userId, + domain, +}: { + serviceUrl: string; + userId: string; + domain: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerU2F({ + userId, + domain, + }); +} + +/** + * + * @param host + * @param request the request object for verifying U2F registration + * @returns the result of the verification + */ +export async function verifyU2FRegistration({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: VerifyU2FRegistrationRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyU2FRegistration(request, {}); +} + +/** + * + * @param host + * @param orgId the organization ID + * @param linking_allowed whether linking is allowed + * @returns the active identity providers + */ +export async function getActiveIdentityProviders({ + serviceUrl, + orgId, + linking_allowed, +}: { + serviceUrl: string; + orgId?: string; + linking_allowed?: boolean; +}) { + const props: any = { ctx: makeReqCtx(orgId) }; + if (linking_allowed) { + props.linkingAllowed = linking_allowed; + } + const settingsService: Client = + await createServiceForHost(SettingsService, serviceUrl); + + return settingsService.getActiveIdentityProviders(props, {}); +} + +/** + * + * @param host + * @param request the request object for verifying passkey registration + * @returns the result of the verification + */ +export async function verifyPasskeyRegistration({ + serviceUrl, + request, +}: { + serviceUrl: string; + request: VerifyPasskeyRegistrationRequest; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.verifyPasskeyRegistration(request, {}); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @param code the code for registering the passkey + * @param domain the domain on which the factor is registered + * @returns the newly set email + */ +export async function registerPasskey({ + serviceUrl, + userId, + code, + domain, +}: { + serviceUrl: string; + userId: string; + code: { id: string; code: string }; + domain: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.registerPasskey({ + userId, + code, + domain, + }); +} + +/** + * + * @param host + * @param userId the id of the user where the email should be set + * @returns the list of authentication method types + */ +export async function listAuthenticationMethodTypes({ + serviceUrl, + userId, +}: { + serviceUrl: string; + userId: string; +}) { + const userService: Client = await createServiceForHost( + UserService, + serviceUrl, + ); + + return userService.listAuthenticationMethodTypes({ + userId, + }); +} + +export function createServerTransport(token: string, baseUrl: string) { + return libCreateServerTransport(token, { + baseUrl, + interceptors: !process.env.CUSTOM_REQUEST_HEADERS + ? undefined + : [ + (next) => { + return (req) => { + process.env + .CUSTOM_REQUEST_HEADERS!.split(",") + .forEach((header) => { + const kv = header.split(":"); + if (kv.length === 2) { + req.header.set(kv[0].trim(), kv[1].trim()); + } else { + console.warn(`Skipping malformed header: ${header}`); + } + }); + return next(req); + }; + }, + ], + }); +} diff --git a/login/apps/login/src/middleware.ts b/login/apps/login/src/middleware.ts new file mode 100644 index 0000000000..8eca00510e --- /dev/null +++ b/login/apps/login/src/middleware.ts @@ -0,0 +1,109 @@ +import { SecuritySettings } from "@zitadel/proto/zitadel/settings/v2/security_settings_pb"; + +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { DEFAULT_CSP } from "../constants/csp"; +import { getServiceUrlFromHeaders } from "./lib/service-url"; +export const config = { + matcher: [ + "/.well-known/:path*", + "/oauth/:path*", + "/oidc/:path*", + "/idps/callback/:path*", + "/saml/:path*", + "/:path*", + ], +}; + +async function loadSecuritySettings( + request: NextRequest, +): Promise { + const securityResponse = await fetch(`${request.nextUrl.origin}/security`); + + if (!securityResponse.ok) { + console.error( + "Failed to fetch security settings:", + securityResponse.statusText, + ); + return null; + } + + const response = await securityResponse.json(); + + if (!response || !response.settings) { + console.error("No security settings found in the response."); + return null; + } + + return response.settings; +} + +export async function middleware(request: NextRequest) { + // Add the original URL as a header to all requests + const requestHeaders = new Headers(request.headers); + + // Extract "organization" search param from the URL and set it as a header if available + const organization = request.nextUrl.searchParams.get("organization"); + if (organization) { + requestHeaders.set("x-zitadel-i18n-organization", organization); + } + + // Only run the rest of the logic for the original matcher paths + const proxyPaths = [ + "/.well-known/", + "/oauth/", + "/oidc/", + "/idps/callback/", + "/saml/", + ]; + + const isMatched = proxyPaths.some((prefix) => + request.nextUrl.pathname.startsWith(prefix), + ); + + // escape proxy if the environment is setup for multitenancy + if ( + !isMatched || + !process.env.ZITADEL_API_URL || + !process.env.ZITADEL_SERVICE_USER_TOKEN + ) { + // For all other routes, just add the header and continue + return NextResponse.next({ + request: { headers: requestHeaders }, + }); + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const instanceHost = `${serviceUrl}` + .replace("https://", "") + .replace("http://", ""); + + // Add additional headers as before + requestHeaders.set("x-zitadel-public-host", `${request.nextUrl.host}`); + requestHeaders.set("x-zitadel-instance-host", instanceHost); + + const responseHeaders = new Headers(); + responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Headers", "*"); + + const securitySettings = await loadSecuritySettings(request); + + if (securitySettings?.embeddedIframe?.enabled) { + responseHeaders.set( + "Content-Security-Policy", + `${DEFAULT_CSP} frame-ancestors ${securitySettings.embeddedIframe.allowedOrigins.join(" ")};`, + ); + responseHeaders.delete("X-Frame-Options"); + } + + request.nextUrl.href = `${serviceUrl}${request.nextUrl.pathname}${request.nextUrl.search}`; + + return NextResponse.rewrite(request.nextUrl, { + request: { + headers: requestHeaders, + }, + headers: responseHeaders, + }); +} diff --git a/login/apps/login/src/styles/globals.scss b/login/apps/login/src/styles/globals.scss new file mode 100755 index 0000000000..cfce853bc7 --- /dev/null +++ b/login/apps/login/src/styles/globals.scss @@ -0,0 +1,65 @@ +// include styles from the ui package +@use "./vars.scss"; + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + h1, + .ztdl-h1 { + @apply text-2xl text-center; + } + + .ztdl-p { + @apply text-sm text-center text-text-light-secondary-500 dark:text-text-dark-secondary-500 text-center; + } +} + +html { + --background-color: #ffffff; + --dark-background-color: #000000; +} + +.form-checkbox:checked { + background-image: url("/checkbox.svg"); +} + +.skeleton { + --accents-2: var(--theme-light-background-400); + --accents-1: var(--theme-light-background-500); + + background-image: linear-gradient( + 270deg, + var(--accents-1), + var(--accents-2), + var(--accents-2), + var(--accents-1) + ); + background-size: 400% 100%; + animation: skeleton_loading 8s ease-in-out infinite; +} + +.dark .skeleton { + --accents-2: var(--theme-dark-background-400); + --accents-1: var(--theme-dark-background-500); + + background-image: linear-gradient( + 270deg, + var(--accents-1), + var(--accents-2), + var(--accents-2), + var(--accents-1) + ); + background-size: 400% 100%; + animation: skeleton_loading 8s ease-in-out infinite; +} + +@keyframes skeleton_loading { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} diff --git a/login/apps/login/src/styles/vars.scss b/login/apps/login/src/styles/vars.scss new file mode 100644 index 0000000000..71c6a28782 --- /dev/null +++ b/login/apps/login/src/styles/vars.scss @@ -0,0 +1,174 @@ +:root { + --theme-dark-primary-50: #f1f7fd; + --theme-dark-primary-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-100: #afd1f2; + --theme-dark-primary-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-200: #7fb5ea; + --theme-dark-primary-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-300: #4192e0; + --theme-dark-primary-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-400: #2782dc; + --theme-dark-primary-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-500: #2073c4; + --theme-dark-primary-contrast-500: #ffffff; + --theme-dark-primary-600: #1c64aa; + --theme-dark-primary-contrast-600: #ffffff; + --theme-dark-primary-700: #17548f; + --theme-dark-primary-contrast-700: #ffffff; + --theme-dark-primary-800: #134575; + --theme-dark-primary-contrast-800: #ffffff; + --theme-dark-primary-900: #0f355b; + --theme-dark-primary-contrast-900: #ffffff; + --theme-dark-primary-A100: #e4f2ff; + --theme-dark-primary-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A200: #7ebfff; + --theme-dark-primary-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A400: #278df0; + --theme-dark-primary-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-dark-primary-A700: #1d80e0; + --theme-dark-primary-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-50: #ffffff; + --theme-light-primary-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-100: #ebedfa; + --theme-light-primary-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-200: #bec6ef; + --theme-light-primary-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-300: #8594e0; + --theme-light-primary-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-400: #6c7eda; + --theme-light-primary-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-500: #5469d4; + --theme-light-primary-contrast-500: #ffffff; + --theme-light-primary-600: #3c54ce; + --theme-light-primary-contrast-600: #ffffff; + --theme-light-primary-700: #2f46bc; + --theme-light-primary-contrast-700: #ffffff; + --theme-light-primary-800: #293da3; + --theme-light-primary-contrast-800: #ffffff; + --theme-light-primary-900: #23348b; + --theme-light-primary-contrast-900: #ffffff; + --theme-light-primary-A100: #ffffff; + --theme-light-primary-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A200: #c5cefc; + --theme-light-primary-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A400: #7085ea; + --theme-light-primary-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-primary-A700: #6478de; + --theme-light-primary-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-50: #ffffff; + --theme-dark-warn-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-100: #fff8f9; + --theme-dark-warn-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-200: #ffc0ca; + --theme-dark-warn-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-300: #ff788e; + --theme-dark-warn-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-400: #ff5a75; + --theme-dark-warn-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-500: #ff3b5b; + --theme-dark-warn-contrast-500: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-600: #ff1c41; + --theme-dark-warn-contrast-600: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-700: #fd0029; + --theme-dark-warn-contrast-700: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-800: #de0024; + --theme-dark-warn-contrast-800: #ffffff; + --theme-dark-warn-900: #c0001f; + --theme-dark-warn-contrast-900: #ffffff; + --theme-dark-warn-A100: #ffffff; + --theme-dark-warn-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A200: #ffd4db; + --theme-dark-warn-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A400: #ff6e86; + --theme-dark-warn-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-dark-warn-A700: #ff5470; + --theme-dark-warn-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-50: #ffffff; + --theme-light-warn-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-100: #f4d3d9; + --theme-light-warn-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-200: #e8a6b2; + --theme-light-warn-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-300: #da6e80; + --theme-light-warn-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-400: #d3556b; + --theme-light-warn-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-500: #cd3d56; + --theme-light-warn-contrast-500: #ffffff; + --theme-light-warn-600: #bb3048; + --theme-light-warn-contrast-600: #ffffff; + --theme-light-warn-700: #a32a3f; + --theme-light-warn-contrast-700: #ffffff; + --theme-light-warn-800: #8a2436; + --theme-light-warn-contrast-800: #ffffff; + --theme-light-warn-900: #721d2c; + --theme-light-warn-contrast-900: #ffffff; + --theme-light-warn-A100: #ffffff; + --theme-light-warn-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A200: #faa9b7; + --theme-light-warn-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A400: #e65770; + --theme-light-warn-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-warn-A700: #d84c64; + --theme-light-warn-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-50: #7c93c6; + --theme-dark-background-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-100: #4a69aa; + --theme-dark-background-contrast-100: #ffffff; + --theme-dark-background-200: #395183; + --theme-dark-background-contrast-200: #ffffff; + --theme-dark-background-300: #243252; + --theme-dark-background-contrast-300: #ffffff; + --theme-dark-background-400: #1a253c; + --theme-dark-background-contrast-400: #ffffff; + --theme-dark-background-500: #111827; + --theme-dark-background-contrast-500: #ffffff; + --theme-dark-background-600: #080b12; + --theme-dark-background-contrast-600: #ffffff; + --theme-dark-background-700: #000000; + --theme-dark-background-contrast-700: #ffffff; + --theme-dark-background-800: #000000; + --theme-dark-background-contrast-800: #ffffff; + --theme-dark-background-900: #000000; + --theme-dark-background-contrast-900: #ffffff; + --theme-dark-background-A100: #5782e0; + --theme-dark-background-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-dark-background-A200: #204eb1; + --theme-dark-background-contrast-A200: #ffffff; + --theme-dark-background-A400: #182b53; + --theme-dark-background-contrast-A400: #ffffff; + --theme-dark-background-A700: #17223b; + --theme-dark-background-contrast-A700: #ffffff; + --theme-light-background-50: #ffffff; + --theme-light-background-contrast-50: hsla(0, 0%, 0%, 0.87); + --theme-light-background-100: #ffffff; + --theme-light-background-contrast-100: hsla(0, 0%, 0%, 0.87); + --theme-light-background-200: #ffffff; + --theme-light-background-contrast-200: hsla(0, 0%, 0%, 0.87); + --theme-light-background-300: #ffffff; + --theme-light-background-contrast-300: hsla(0, 0%, 0%, 0.87); + --theme-light-background-400: #ffffff; + --theme-light-background-contrast-400: hsla(0, 0%, 0%, 0.87); + --theme-light-background-500: #fafafa; + --theme-light-background-contrast-500: hsla(0, 0%, 0%, 0.87); + --theme-light-background-600: #ebebeb; + --theme-light-background-contrast-600: hsla(0, 0%, 0%, 0.87); + --theme-light-background-700: #dbdbdb; + --theme-light-background-contrast-700: hsla(0, 0%, 0%, 0.87); + --theme-light-background-800: #cccccc; + --theme-light-background-contrast-800: hsla(0, 0%, 0%, 0.87); + --theme-light-background-900: #bdbdbd; + --theme-light-background-contrast-900: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A100: #ffffff; + --theme-light-background-contrast-A100: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A200: #ffffff; + --theme-light-background-contrast-A200: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A400: #ffffff; + --theme-light-background-contrast-A400: hsla(0, 0%, 0%, 0.87); + --theme-light-background-A700: #ffffff; + --theme-light-background-contrast-A700: hsla(0, 0%, 0%, 0.87); + --theme-dark-text: #ffffff; + --theme-dark-secondary-text: #ffffffc7; + --theme-light-text: #000000; + --theme-light-secondary-text: #000000c7; +} diff --git a/login/apps/login/tailwind.config.mjs b/login/apps/login/tailwind.config.mjs new file mode 100644 index 0000000000..908068e5dd --- /dev/null +++ b/login/apps/login/tailwind.config.mjs @@ -0,0 +1,117 @@ +import sharedConfig from "@zitadel/tailwind-config/tailwind.config.mjs"; + +let colors = { + background: { light: { contrast: {} }, dark: { contrast: {} } }, + primary: { light: { contrast: {} }, dark: { contrast: {} } }, + warn: { light: { contrast: {} }, dark: { contrast: {} } }, + text: { light: { contrast: {} }, dark: { contrast: {} } }, + link: { light: { contrast: {} }, dark: { contrast: {} } }, +}; + +const shades = [ + "50", + "100", + "200", + "300", + "400", + "500", + "600", + "700", + "800", + "900", +]; +const themes = ["light", "dark"]; +const types = ["background", "primary", "warn", "text", "link"]; +types.forEach((type) => { + themes.forEach((theme) => { + shades.forEach((shade) => { + colors[type][theme][shade] = `var(--theme-${theme}-${type}-${shade})`; + colors[type][theme][`contrast-${shade}`] = + `var(--theme-${theme}-${type}-contrast-${shade})`; + colors[type][theme][`secondary-${shade}`] = + `var(--theme-${theme}-${type}-secondary-${shade})`; + }); + }); +}); + +/** @type {import('tailwindcss').Config} */ +export default { + presets: [sharedConfig], + darkMode: "class", + content: ["./src/**/*.{js,ts,jsx,tsx}"], + future: { + hoverOnlyWhenSupported: true, + }, + theme: { + extend: { + colors: { + ...colors, + state: { + success: { + light: { + background: "#cbf4c9", + color: "#0e6245", + }, + dark: { + background: "#68cf8340", + color: "#cbf4c9", + }, + }, + error: { + light: { + background: "#ffc1c1", + color: "#620e0e", + }, + dark: { + background: "#af455359", + color: "#ffc1c1", + }, + }, + neutral: { + light: { + background: "#e4e7e4", + color: "#000000", + }, + dark: { + background: "#1a253c", + color: "#ffffff", + }, + }, + alert: { + light: { + background: "#fbbf24", + color: "#92400e", + }, + dark: { + background: "#92400e50", + color: "#fbbf24", + }, + }, + }, + }, + animation: { + shake: "shake .8s cubic-bezier(.36,.07,.19,.97) both;", + }, + keyframes: { + shake: { + "10%, 90%": { + transform: "translate3d(-1px, 0, 0)", + }, + + "20%, 80%": { + transform: "translate3d(2px, 0, 0)", + }, + + "30%, 50%, 70%": { + transform: "translate3d(-4px, 0, 0)", + }, + + "40%, 60%": { + transform: "translate3d(4px, 0, 0)", + }, + }, + }, + }, + }, + plugins: [require("@tailwindcss/forms")], +}; diff --git a/login/apps/login/tsconfig.json b/login/apps/login/tsconfig.json new file mode 100755 index 0000000000..c855c43225 --- /dev/null +++ b/login/apps/login/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "@zitadel/tsconfig/nextjs.json", + "compilerOptions": { + "jsx": "preserve", + "target": "es2022", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "custom-config.js" + ], + "exclude": ["node_modules"] +} diff --git a/login/apps/login/turbo.json b/login/apps/login/turbo.json new file mode 100644 index 0000000000..bc63a2dbc4 --- /dev/null +++ b/login/apps/login/turbo.json @@ -0,0 +1,22 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"], + "dependsOn": ["^build"] + }, + "build:login:standalone": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"] + }, + "test": { + "dependsOn": ["@zitadel/client#build"] + }, + "test:unit": { + "dependsOn": ["@zitadel/client#build"] + }, + "test:unit:standalone": {}, + "test:watch": { + "dependsOn": ["@zitadel/client#build"] + } + } +} diff --git a/login/apps/login/vitest.config.mts b/login/apps/login/vitest.config.mts new file mode 100644 index 0000000000..238c5b8b93 --- /dev/null +++ b/login/apps/login/vitest.config.mts @@ -0,0 +1,12 @@ +import react from "@vitejs/plugin-react"; +import tsconfigPaths from "vite-tsconfig-paths"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [tsconfigPaths(), react()], + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + environment: "jsdom", + setupFiles: ["@testing-library/jest-dom/vitest"], + }, +}); diff --git a/login/docker-bake.hcl b/login/docker-bake.hcl new file mode 100644 index 0000000000..9520b752fa --- /dev/null +++ b/login/docker-bake.hcl @@ -0,0 +1,145 @@ +variable "LOGIN_DIR" { + default = "./" +} + +variable "DOCKERFILES_DIR" { + default = "dockerfiles/" +} + +# typescript-proto-client is used to generate the client code for the login service. +# It is not login-prefixed, so it is easily extendable. +# To extend this bake-file.hcl, set the context of all login-prefixed targets to a different directory. +# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/ +# The zitadel repository uses this to generate the client and the mock server from local proto files. +target "typescript-proto-client" { + dockerfile = "${DOCKERFILES_DIR}typescript-proto-client.Dockerfile" + contexts = { + # We directly generate and download the client server-side with buf, so we don't need the proto files + login-pnpm = "target:login-pnpm" + } +} + +# We prefix the target with login- so we can reuse the writing of protos if we overwrite the typescript-proto-client target. +target "login-typescript-proto-client-out" { + dockerfile = "${DOCKERFILES_DIR}login-typescript-proto-client-out.Dockerfile" + contexts = { + typescript-proto-client = "target:typescript-proto-client" + } + output = [ + "type=local,dest=${LOGIN_DIR}packages/zitadel-proto" + ] +} + +# proto-files is only used to build core-mock against which the integration tests run. +# To build the proto-client, we use buf to generate and download the client code directly. +# It is not login-prefixed, so it is easily extendable. +# To extend this bake-file.hcl, set the context of all login-prefixed targets to a different directory. +# For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/ +# The zitadel repository uses this to generate the client and the mock server from local proto files. +target "proto-files" { + dockerfile = "${DOCKERFILES_DIR}proto-files.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } +} + +variable "NODE_VERSION" { + default = "20" +} + +target "login-pnpm" { + dockerfile = "${DOCKERFILES_DIR}login-pnpm.Dockerfile" + args = { + NODE_VERSION = "${NODE_VERSION}" + } +} + +target "login-dev-base" { + dockerfile = "${DOCKERFILES_DIR}login-dev-base.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } +} + +target "login-lint" { + dockerfile = "${DOCKERFILES_DIR}login-lint.Dockerfile" + contexts = { + login-dev-base = "target:login-dev-base" + } +} + +target "login-test-unit" { + dockerfile = "${DOCKERFILES_DIR}login-test-unit.Dockerfile" + contexts = { + login-client = "target:login-client" + } +} + +target "login-client" { + dockerfile = "${DOCKERFILES_DIR}login-client.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + typescript-proto-client = "target:typescript-proto-client" + } +} + +variable "LOGIN_CORE_MOCK_TAG" { + default = "login-core-mock:local" +} + +# the core-mock context must not be overwritten, so we don't prefix it with login-. +target "core-mock" { + context = "${LOGIN_DIR}apps/login-test-integration/core-mock" + contexts = { + protos = "target:proto-files" + } + tags = ["${LOGIN_CORE_MOCK_TAG}"] +} + +variable "LOGIN_TEST_INTEGRATION_TAG" { + default = "login-test-integration:local" +} + +target "login-test-integration" { + dockerfile = "${DOCKERFILES_DIR}login-test-integration.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } + tags = ["${LOGIN_TEST_INTEGRATION_TAG}"] +} + +variable "LOGIN_TEST_ACCEPTANCE_TAG" { + default = "login-test-acceptance:local" +} + +target "login-test-acceptance" { + dockerfile = "${DOCKERFILES_DIR}login-test-acceptance.Dockerfile" + contexts = { + login-pnpm = "target:login-pnpm" + } + tags = ["${LOGIN_TEST_ACCEPTANCE_TAG}"] +} + +variable "LOGIN_TAG" { + default = "zitadel-login:local" +} + +target "docker-metadata-action" {} + +# We run integration and acceptance tests against the next standalone server for docker. +target "login-standalone" { + inherits = ["docker-metadata-action"] + dockerfile = "${DOCKERFILES_DIR}login-standalone.Dockerfile" + contexts = { + login-client = "target:login-client" + } + tags = ["${LOGIN_TAG}"] +} + +target "login-standalone-out" { + inherits = ["login-standalone"] + target = "login-standalone-out" + output = [ + "type=local,dest=${LOGIN_DIR}apps/login/standalone" + ] +} diff --git a/login/dockerfiles/login-client.Dockerfile b/login/dockerfiles/login-client.Dockerfile new file mode 100644 index 0000000000..4eb01615b4 --- /dev/null +++ b/login/dockerfiles/login-client.Dockerfile @@ -0,0 +1,7 @@ +FROM typescript-proto-client AS login-client +COPY packages/zitadel-tsconfig packages/zitadel-tsconfig +COPY packages/zitadel-client/package.json ./packages/zitadel-client/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter ./packages/zitadel-client +COPY packages/zitadel-client ./packages/zitadel-client +RUN pnpm build:client:standalone diff --git a/login/dockerfiles/login-client.Dockerfile.dockerignore b/login/dockerfiles/login-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..c2302359f5 --- /dev/null +++ b/login/dockerfiles/login-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* + +!packages/zitadel-client +packages/zitadel-client/dist + +!packages/zitadel-tsconfig + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-dev-base.Dockerfile b/login/dockerfiles/login-dev-base.Dockerfile new file mode 100644 index 0000000000..e102d16746 --- /dev/null +++ b/login/dockerfiles/login-dev-base.Dockerfile @@ -0,0 +1,3 @@ +FROM login-pnpm AS login-dev-base +RUN pnpm install --frozen-lockfile --prefer-offline --workspace-root --filter . + diff --git a/login/dockerfiles/login-dev-base.Dockerfile.dockerignore b/login/dockerfiles/login-dev-base.Dockerfile.dockerignore new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/login/dockerfiles/login-dev-base.Dockerfile.dockerignore @@ -0,0 +1 @@ +* diff --git a/login/dockerfiles/login-lint.Dockerfile b/login/dockerfiles/login-lint.Dockerfile new file mode 100644 index 0000000000..0c466b4cfa --- /dev/null +++ b/login/dockerfiles/login-lint.Dockerfile @@ -0,0 +1,7 @@ +FROM login-dev-base AS login-lint +COPY .prettierrc .prettierignore ./ +COPY apps/login/package.json apps/login/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter apps/login +COPY . . +RUN pnpm lint && pnpm format diff --git a/login/dockerfiles/login-lint.Dockerfile.dockerignore b/login/dockerfiles/login-lint.Dockerfile.dockerignore new file mode 100644 index 0000000000..1029f73c02 --- /dev/null +++ b/login/dockerfiles/login-lint.Dockerfile.dockerignore @@ -0,0 +1,25 @@ +* + +!apps/login +apps/login/.next +apps/login/dist +apps/login/screenshots +apps/login/standalone +apps/login/.env*.local + +!apps/login-test-integration + +!apps/login-test-acceptance +apps/login-test-acceptance/test-results + +!/packages/zitadel-tsconfig/* +!/packages/zitadel-prettier-config +!/packages/zitadel-eslint-config + +!/.prettierrc +!/.prettierignore + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-pnpm.Dockerfile b/login/dockerfiles/login-pnpm.Dockerfile new file mode 100644 index 0000000000..bcef6d126c --- /dev/null +++ b/login/dockerfiles/login-pnpm.Dockerfile @@ -0,0 +1,10 @@ +ARG NODE_VERSION=20 +FROM node:${NODE_VERSION}-bookworm AS login-pnpm +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack prepare pnpm@9.1.2 --activate && \ + apt-get update && apt-get install -y --no-install-recommends && \ + rm -rf /var/lib/apt/lists/* +WORKDIR /build +COPY turbo.json .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +ENTRYPOINT ["pnpm"] diff --git a/login/dockerfiles/login-pnpm.Dockerfile.dockerignore b/login/dockerfiles/login-pnpm.Dockerfile.dockerignore new file mode 100644 index 0000000000..067514fdd3 --- /dev/null +++ b/login/dockerfiles/login-pnpm.Dockerfile.dockerignore @@ -0,0 +1,6 @@ +* +!/turbo.json +!/.npmrc +!/package.json +!/pnpm-lock.yaml +!/pnpm-workspace.yaml diff --git a/login/dockerfiles/login-standalone.Dockerfile b/login/dockerfiles/login-standalone.Dockerfile new file mode 100644 index 0000000000..7e97d344db --- /dev/null +++ b/login/dockerfiles/login-standalone.Dockerfile @@ -0,0 +1,34 @@ +FROM login-client AS login-standalone-builder +COPY apps/login ./apps/login +COPY packages/zitadel-tailwind-config packages/zitadel-tailwind-config +RUN pnpm exec turbo prune @zitadel/login --docker +WORKDIR /build/docker +RUN cp -r ../out/json/* . +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile +RUN cp -r ../out/full/* . +RUN pnpm exec turbo run build:login:standalone + +FROM scratch AS login-standalone-out +COPY --from=login-standalone-builder /build/docker/apps/login/.next/standalone / +COPY --from=login-standalone-builder /build/docker/apps/login/.next/static /apps/login/.next/static +COPY --from=login-standalone-builder /build/docker/apps/login/public /apps/login/public + +FROM node:20-alpine AS login-standalone +WORKDIR /runtime +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs +# If /.env-file/.env is mounted into the container, its variables are made available to the server before it starts up. +RUN mkdir -p /.env-file && touch /.env-file/.env && chown -R nextjs:nodejs /.env-file +COPY ./scripts/entrypoint.sh ./ +COPY ./scripts/healthcheck.js ./ +COPY --chown=nextjs:nodejs --from=login-standalone-builder /build/docker/apps/login/.next/standalone ./ +COPY --chown=nextjs:nodejs --from=login-standalone-builder /build/docker/apps/login/.next/static ./apps/login/.next/static +COPY --chown=nextjs:nodejs --from=login-standalone-builder /build/docker/apps/login/public ./apps/login/public +USER nextjs +ENV HOSTNAME="0.0.0.0" +ENV PORT=3000 +# TODO: Check healthy, not ready +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ +CMD ["/bin/sh", "-c", "node ./healthcheck.js http://localhost:${PORT}/ui/v2/login/healthy"] +ENTRYPOINT ["./entrypoint.sh"] diff --git a/login/dockerfiles/login-standalone.Dockerfile.dockerignore b/login/dockerfiles/login-standalone.Dockerfile.dockerignore new file mode 100644 index 0000000000..f876e1e9f1 --- /dev/null +++ b/login/dockerfiles/login-standalone.Dockerfile.dockerignore @@ -0,0 +1,17 @@ +* + +!apps/login +apps/login/.next +apps/login/dist +apps/login/screenshots +apps/login/standalone +apps/login/.env*.local + +!scripts/entrypoint.sh +!scripts/healthcheck.js +!packages/zitadel-tailwind-config + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-test-acceptance.Dockerfile b/login/dockerfiles/login-test-acceptance.Dockerfile new file mode 100644 index 0000000000..7052484779 --- /dev/null +++ b/login/dockerfiles/login-test-acceptance.Dockerfile @@ -0,0 +1,8 @@ +FROM login-pnpm AS login-test-acceptance-dependencies +COPY ./apps/login-test-acceptance/package.json ./apps/login-test-acceptance/package.json +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --filter=login-test-acceptance && \ + cd apps/login-test-acceptance && \ + pnpm exec playwright install --with-deps chromium +COPY ./apps/login-test-acceptance ./apps/login-test-acceptance +CMD ["bash", "-c", "cd apps/login-test-acceptance && pnpm test:acceptance test"] diff --git a/login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore b/login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore new file mode 100644 index 0000000000..cba55ae91e --- /dev/null +++ b/login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore @@ -0,0 +1,5 @@ +* +!/apps/login-test-acceptance/*.json +!/apps/login-test-acceptance/*.ts +!/apps/login-test-acceptance/zitadel.yaml +!/apps/login-test-acceptance/tests diff --git a/login/dockerfiles/login-test-integration.Dockerfile b/login/dockerfiles/login-test-integration.Dockerfile new file mode 100644 index 0000000000..0b55dc2b1a --- /dev/null +++ b/login/dockerfiles/login-test-integration.Dockerfile @@ -0,0 +1,11 @@ +FROM login-pnpm AS login-test-integration-dependencies +COPY ./apps/login-test-integration/package.json ./apps/login-test-integration/package.json +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --filter=login-test-integration +FROM cypress/factory:5.10.0 AS login-test-integration +WORKDIR /opt/app +COPY --from=login-test-integration-dependencies /build/apps/login-test-integration . +RUN npm install cypress +RUN npx cypress install +COPY ./apps/login-test-integration . +CMD ["npx", "cypress", "run"] diff --git a/login/dockerfiles/login-test-integration.Dockerfile.dockerignore b/login/dockerfiles/login-test-integration.Dockerfile.dockerignore new file mode 100644 index 0000000000..947a4fdb57 --- /dev/null +++ b/login/dockerfiles/login-test-integration.Dockerfile.dockerignore @@ -0,0 +1,9 @@ +* + +!/apps/login-test-integration +/apps/login-test-integration/core-mock + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-test-unit.Dockerfile b/login/dockerfiles/login-test-unit.Dockerfile new file mode 100644 index 0000000000..d456a4fac4 --- /dev/null +++ b/login/dockerfiles/login-test-unit.Dockerfile @@ -0,0 +1,6 @@ +FROM login-client AS login-test-unit +COPY apps/login/package.json ./apps/login/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter ./apps/login +COPY apps/login ./apps/login +RUN pnpm test:unit:standalone diff --git a/login/dockerfiles/login-test-unit.Dockerfile.dockerignore b/login/dockerfiles/login-test-unit.Dockerfile.dockerignore new file mode 100644 index 0000000000..2263653c69 --- /dev/null +++ b/login/dockerfiles/login-test-unit.Dockerfile.dockerignore @@ -0,0 +1,13 @@ +* + +!apps/login +apps/login/.next +apps/login/dist +apps/login/screenshots +apps/login/standalone +apps/login/.env*.local + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/login/dockerfiles/login-typescript-proto-client-out.Dockerfile b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile new file mode 100644 index 0000000000..3aa3c9d7d6 --- /dev/null +++ b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile @@ -0,0 +1,5 @@ +FROM scratch AS typescript-proto-client-out +COPY --from=typescript-proto-client /build/packages/zitadel-proto/zitadel /zitadel +COPY --from=typescript-proto-client /build/packages/zitadel-proto/google /google +COPY --from=typescript-proto-client /build/packages/zitadel-proto/protoc-gen-openapiv2 /protoc-gen-openapiv2 +COPY --from=typescript-proto-client /build/packages/zitadel-proto/validate /validate diff --git a/login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore @@ -0,0 +1 @@ +* diff --git a/login/dockerfiles/proto-files.Dockerfile b/login/dockerfiles/proto-files.Dockerfile new file mode 100644 index 0000000000..f97f63a718 --- /dev/null +++ b/login/dockerfiles/proto-files.Dockerfile @@ -0,0 +1,8 @@ +FROM bufbuild/buf:1.54.0 AS proto-files +RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \ + buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto-files && \ + buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto-files && \ + buf export https://github.com/zitadel/zitadel.git --path ./proto/zitadel --output /proto-files + +FROM scratch +COPY --from=proto-files /proto-files / diff --git a/login/dockerfiles/proto-files.Dockerfile.dockerignore b/login/dockerfiles/proto-files.Dockerfile.dockerignore new file mode 100644 index 0000000000..72e8ffc0db --- /dev/null +++ b/login/dockerfiles/proto-files.Dockerfile.dockerignore @@ -0,0 +1 @@ +* diff --git a/login/dockerfiles/typescript-proto-client.Dockerfile b/login/dockerfiles/typescript-proto-client.Dockerfile new file mode 100644 index 0000000000..ee0848f52d --- /dev/null +++ b/login/dockerfiles/typescript-proto-client.Dockerfile @@ -0,0 +1,6 @@ +FROM login-pnpm AS typescript-proto-client +COPY packages/zitadel-proto/package.json ./packages/zitadel-proto/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter zitadel-proto +COPY packages/zitadel-proto ./packages/zitadel-proto +RUN pnpm generate diff --git a/login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore b/login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..e67848e8c3 --- /dev/null +++ b/login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* +!/packages/zitadel-proto/ +packages/zitadel-proto/google +packages/zitadel-proto/zitadel +packages/zitadel-proto/protoc-gen-openapiv2 +packages/zitadel-proto/validate + +**/*.md +**/*.png +**/node_modules +**/.turbo \ No newline at end of file diff --git a/login/meta.json b/login/meta.json new file mode 100644 index 0000000000..2c443f76cf --- /dev/null +++ b/login/meta.json @@ -0,0 +1,4 @@ +{ + "name": "ZITADEL typescript Monorepo with Changesets", + "description": "ZITADEL typescript monorepo preconfigured to publish packages via Changesets" +} diff --git a/login/package.json b/login/package.json new file mode 100644 index 0000000000..ce844c4b2c --- /dev/null +++ b/login/package.json @@ -0,0 +1,55 @@ +{ + "packageManager": "pnpm@9.1.2+sha256.19c17528f9ca20bd442e4ca42f00f1b9808a9cb419383cd04ba32ef19322aba7", + "private": true, + "name": "typescript-monorepo", + "scripts": { + "generate": "pnpm exec turbo run generate", + "build": "pnpm exec turbo run build", + "build:client:standalone": "pnpm exec turbo run build:client:standalone", + "build:login:standalone": "pnpm exec turbo run build:login:standalone", + "build:packages": "pnpm exec turbo run build --filter=./packages/*", + "build:apps": "pnpm exec turbo run build --filter=./apps/*", + "test": "pnpm exec turbo run test", + "start": "pnpm exec turbo run start", + "start:built": "pnpm exec turbo run start:built", + "test:unit": "pnpm exec turbo run test:unit -- --passWithNoTests", + "test:unit:standalone": "pnpm exec turbo run test:unit:standalone -- --passWithNoTests", + "test:integration": "cd apps/login-test-integration && pnpm test:integration", + "test:integration:setup": "NODE_ENV=test pnpm exec turbo run test:integration:setup", + "test:acceptance": "cd apps/login-test-acceptance && pnpm test:acceptance", + "test:acceptance:setup": "cd apps/login-test-acceptance && pnpm test:acceptance:setup", + "test:watch": "pnpm exec turbo run test:watch", + "dev": "pnpm exec turbo run dev --no-cache --continue", + "dev:local": "pnpm test:acceptance:setup", + "lint": "pnpm exec turbo run lint", + "lint:fix": "pnpm exec turbo run lint:fix", + "clean": "pnpm exec turbo run clean && rm -rf node_modules", + "format:fix": "pnpm exec prettier --write \"**/*.{ts,tsx,md}\"", + "format": "pnpm exec prettier --check \"**/*.{ts,tsx,md}\"", + "changeset": "pnpm exec changeset", + "version-packages": "pnpm exec changeset version", + "release": "pnpm exec turbo run build --filter=login^... && pnpm exec changeset publish" + }, + "pnpm": { + "overrides": { + "@typescript-eslint/parser": "^7.9.0" + } + }, + "devDependencies": { + "@changesets/cli": "^2.29.2", + "@vitejs/plugin-react": "^4.4.1", + "@zitadel/eslint-config": "workspace:*", + "@zitadel/prettier-config": "workspace:*", + "axios": "^1.8.4", + "dotenv": "^16.5.0", + "dotenv-cli": "^8.0.0", + "eslint": "8.57.1", + "prettier": "^3.5.3", + "prettier-plugin-organize-imports": "^4.1.0", + "tsup": "^8.4.0", + "turbo": "2.5.0", + "typescript": "^5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.1.2" + } +} diff --git a/login/packages/zitadel-client/.eslintrc.cjs b/login/packages/zitadel-client/.eslintrc.cjs new file mode 100644 index 0000000000..0eb32ca20d --- /dev/null +++ b/login/packages/zitadel-client/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ["@zitadel/eslint-config"], +}; diff --git a/login/packages/zitadel-client/.gitignore b/login/packages/zitadel-client/.gitignore new file mode 100644 index 0000000000..8ff894e88c --- /dev/null +++ b/login/packages/zitadel-client/.gitignore @@ -0,0 +1,4 @@ +src/proto +node_modules +dist +.turbo diff --git a/login/packages/zitadel-client/CHANGELOG.md b/login/packages/zitadel-client/CHANGELOG.md new file mode 100644 index 0000000000..f1107bcc5a --- /dev/null +++ b/login/packages/zitadel-client/CHANGELOG.md @@ -0,0 +1,77 @@ +# @zitadel/client + +## 1.2.0 + +### Minor Changes + +- 62ad388: revert CJS support + +## 1.1.0 + +### Minor Changes + +- 9692297: add CJS and ESM support + +## 1.0.7 + +### Patch Changes + +- Updated dependencies [97b0332] + - @zitadel/proto@1.0.4 + +## 1.0.6 + +### Patch Changes + +- 90fbdd1: use node16/nodenext module resolution +- Updated dependencies [90fbdd1] + - @zitadel/proto@1.0.3 + +## 1.0.5 + +### Patch Changes + +- 4fa22c0: fix export for grpcweb transport + +## 1.0.4 + +### Patch Changes + +- 28dc956: dynamic properties for system token utility + +## 1.0.3 + +### Patch Changes + +- ef1c801: add missing client transport utility + +## 1.0.2 + +### Patch Changes + +- Updated dependencies + - @zitadel/proto@1.0.2 + +## 1.0.1 + +### Patch Changes + +- README updates +- Updated dependencies + - @zitadel/proto@1.0.1 + +## 1.0.0 + +### Major Changes + +- 32e1199: Initial Release + +### Minor Changes + +- f32ab7f: Initial release + +### Patch Changes + +- Updated dependencies [f32ab7f] +- Updated dependencies [32e1199] + - @zitadel/proto@1.0.0 diff --git a/login/packages/zitadel-client/README.md b/login/packages/zitadel-client/README.md new file mode 100644 index 0000000000..0a14fe32f5 --- /dev/null +++ b/login/packages/zitadel-client/README.md @@ -0,0 +1,53 @@ +# ZITADEL Client + +This package exports services and utilities to interact with ZITADEL + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/client +``` + +or + +```sh +yarn add @zitadel/client +``` + +## Usage + +### Importing Services + +You can import and use the services provided by this package to interact with ZITADEL. + +```ts +import { createSettingsServiceClient, makeReqCtx } from "@zitadel/client/v2"; + +// Example usage +const transport = createServerTransport(process.env.ZITADEL_SERVICE_USER_TOKEN!, { baseUrl: process.env.ZITADEL_API_URL! }); + +const settingsService = createSettingsServiceClient(transport); + +settingsService.getBrandingSettings({ ctx: makeReqCtx("orgId") }, {}); +``` + +### Utilities + +This package also provides various utilities to work with ZITADEL + +```ts +import { timestampMs } from "@zitadel/client"; + +// Example usage +console.log(`${timestampMs(session.creationDate)}`); +``` + +## Documentation + +For detailed documentation and API references, please visit the [ZITADEL documentation](https://zitadel.com/docs). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-client/package.json b/login/packages/zitadel-client/package.json new file mode 100644 index 0000000000..298f54f088 --- /dev/null +++ b/login/packages/zitadel-client/package.json @@ -0,0 +1,71 @@ +{ + "name": "@zitadel/client", + "version": "1.2.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./v1": { + "types": "./dist/v1.d.ts", + "import": "./dist/v1.js", + "require": "./dist/v1.cjs" + }, + "./v2": { + "types": "./dist/v2.d.ts", + "import": "./dist/v2.js", + "require": "./dist/v2.cjs" + }, + "./v3alpha": { + "types": "./dist/v3alpha.d.ts", + "import": "./dist/v3alpha.js", + "require": "./dist/v3alpha.cjs" + }, + "./node": { + "types": "./dist/node.d.ts", + "import": "./dist/node.js", + "require": "./dist/node.cjs" + }, + "./web": { + "types": "./dist/web.d.ts", + "import": "./dist/web.js", + "require": "./dist/web.cjs" + } + }, + "files": [ + "dist/**" + ], + "sideEffects": false, + "scripts": { + "build": "pnpm exec tsup", + "build:client:standalone": "pnpm build", + "test": "pnpm test:unit", + "test:watch": "pnpm test:unit:watch", + "test:unit": "pnpm exec vitest", + "test:unit:standalone": "pnpm test:unit", + "test:unit:watch": "pnpm exec vitest --watch", + "dev": "pnpm exec tsup --watch --dts", + "lint": "eslint \"src/**/*.ts*\"", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.2", + "@connectrpc/connect": "^2.0.0", + "@connectrpc/connect-node": "^2.0.0", + "@connectrpc/connect-web": "^2.0.0", + "jose": "^5.3.0", + "@zitadel/proto": "workspace:*" + }, + "devDependencies": { + "@bufbuild/protocompile": "^0.0.1", + "@bufbuild/buf": "^1.53.0", + "@zitadel/tsconfig": "workspace:*", + "@zitadel/eslint-config": "workspace:*" + } +} diff --git a/login/packages/zitadel-client/src/helpers.ts b/login/packages/zitadel-client/src/helpers.ts new file mode 100644 index 0000000000..637cadf538 --- /dev/null +++ b/login/packages/zitadel-client/src/helpers.ts @@ -0,0 +1,11 @@ +import type { DescService } from "@bufbuild/protobuf"; +import { Timestamp, timestampDate } from "@bufbuild/protobuf/wkt"; +import { createClient, Transport } from "@connectrpc/connect"; + +export function createClientFor(service: TService) { + return (transport: Transport) => createClient(service, transport); +} + +export function toDate(timestamp: Timestamp | undefined): Date | undefined { + return timestamp ? timestampDate(timestamp) : undefined; +} diff --git a/login/packages/zitadel-client/src/index.ts b/login/packages/zitadel-client/src/index.ts new file mode 100644 index 0000000000..f3f93e593f --- /dev/null +++ b/login/packages/zitadel-client/src/index.ts @@ -0,0 +1,10 @@ +export { createClientFor, toDate } from "./helpers.js"; +export { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +// TODO: Move this to `./protobuf.ts` and export it from there +export { create, fromJson, toJson } from "@bufbuild/protobuf"; +export type { JsonObject } from "@bufbuild/protobuf"; +export type { GenService } from "@bufbuild/protobuf/codegenv1"; +export { TimestampSchema, timestampDate, timestampFromDate, timestampFromMs, timestampMs } from "@bufbuild/protobuf/wkt"; +export type { Duration, Timestamp } from "@bufbuild/protobuf/wkt"; +export type { Client, Code, ConnectError } from "@connectrpc/connect"; diff --git a/login/packages/zitadel-client/src/interceptors.test.ts b/login/packages/zitadel-client/src/interceptors.test.ts new file mode 100644 index 0000000000..a5100f866a --- /dev/null +++ b/login/packages/zitadel-client/src/interceptors.test.ts @@ -0,0 +1,67 @@ +import { Int32Value } from "@bufbuild/protobuf/wkt"; +import { compileService } from "@bufbuild/protocompile"; +import { createRouterTransport, HandlerContext } from "@connectrpc/connect"; +import { describe, expect, test, vitest } from "vitest"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +const TestService = compileService(` + syntax = "proto3"; + package handwritten; + service TestService { + rpc Unary(Int32Value) returns (StringValue); + } + message Int32Value { + int32 value = 1; + } + message StringValue { + string value = 1; + } +`); + +describe("NewAuthorizationBearerInterceptor", () => { + const transport = { + interceptors: [NewAuthorizationBearerInterceptor("mytoken")], + }; + + test("injects the authorization token", async () => { + const handler = vitest.fn((request: Int32Value, context: HandlerContext) => { + return { value: request.value.toString() }; + }); + + const service = createRouterTransport( + ({ rpc }) => { + rpc(TestService.method.unary, handler); + }, + { transport }, + ); + + await service.unary(TestService.method.unary, undefined, undefined, {}, { value: 9001 }); + + expect(handler).toBeCalled(); + expect(handler.mock.calls[0][1].requestHeader.get("Authorization")).toBe("Bearer mytoken"); + }); + + test("do not overwrite the previous authorization token", async () => { + const handler = vitest.fn((request: Int32Value, context: HandlerContext) => { + return { value: request.value.toString() }; + }); + + const service = createRouterTransport( + ({ rpc }) => { + rpc(TestService.method.unary, handler); + }, + { transport }, + ); + + await service.unary( + TestService.method.unary, + undefined, + undefined, + { Authorization: "Bearer somethingelse" }, + { value: 9001 }, + ); + + expect(handler).toBeCalled(); + expect(handler.mock.calls[0][1].requestHeader.get("Authorization")).toBe("Bearer somethingelse"); + }); +}); diff --git a/login/packages/zitadel-client/src/interceptors.ts b/login/packages/zitadel-client/src/interceptors.ts new file mode 100644 index 0000000000..9d719c7d70 --- /dev/null +++ b/login/packages/zitadel-client/src/interceptors.ts @@ -0,0 +1,16 @@ +import type { Interceptor } from "@connectrpc/connect"; + +/** + * Creates an interceptor that adds an Authorization header with a Bearer token. + * @param token + */ +export function NewAuthorizationBearerInterceptor(token: string): Interceptor { + return (next) => (req) => { + // TODO: I am not what is the intent of checking for the Authorization header + // and setting it if it is not present. + if (!req.header.get("Authorization")) { + req.header.set("Authorization", `Bearer ${token}`); + } + return next(req); + }; +} diff --git a/login/packages/zitadel-client/src/node.ts b/login/packages/zitadel-client/src/node.ts new file mode 100644 index 0000000000..0b15310d2c --- /dev/null +++ b/login/packages/zitadel-client/src/node.ts @@ -0,0 +1,36 @@ +import { createGrpcTransport, GrpcTransportOptions } from "@connectrpc/connect-node"; +import { importPKCS8, SignJWT } from "jose"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +/** + * Create a server transport using grpc with the given token and configuration options. + * @param token + * @param opts + */ +export function createServerTransport(token: string, opts: GrpcTransportOptions) { + return createGrpcTransport({ + ...opts, + interceptors: [...(opts.interceptors || []), NewAuthorizationBearerInterceptor(token)], + }); +} + +export async function newSystemToken({ + audience, + subject, + key, + expirationTime, +}: { + audience: string; + subject: string; + key: string; + expirationTime?: number | string | Date; +}) { + return await new SignJWT({}) + .setProtectedHeader({ alg: "RS256" }) + .setIssuedAt() + .setExpirationTime(expirationTime ?? "1h") + .setIssuer(subject) + .setSubject(subject) + .setAudience(audience) + .sign(await importPKCS8(key, "RS256")); +} diff --git a/login/packages/zitadel-client/src/v1.ts b/login/packages/zitadel-client/src/v1.ts new file mode 100644 index 0000000000..d04180cf88 --- /dev/null +++ b/login/packages/zitadel-client/src/v1.ts @@ -0,0 +1,11 @@ +import { createClientFor } from "./helpers.js"; + +import { AdminService } from "@zitadel/proto/zitadel/admin_pb.js"; +import { AuthService } from "@zitadel/proto/zitadel/auth_pb.js"; +import { ManagementService } from "@zitadel/proto/zitadel/management_pb.js"; +import { SystemService } from "@zitadel/proto/zitadel/system_pb.js"; + +export const createAdminServiceClient = createClientFor(AdminService); +export const createAuthServiceClient = createClientFor(AuthService); +export const createManagementServiceClient = createClientFor(ManagementService); +export const createSystemServiceClient = createClientFor(SystemService); diff --git a/login/packages/zitadel-client/src/v2.ts b/login/packages/zitadel-client/src/v2.ts new file mode 100644 index 0000000000..49cf901734 --- /dev/null +++ b/login/packages/zitadel-client/src/v2.ts @@ -0,0 +1,27 @@ +import { create } from "@bufbuild/protobuf"; +import { FeatureService } from "@zitadel/proto/zitadel/feature/v2/feature_service_pb.js"; +import { IdentityProviderService } from "@zitadel/proto/zitadel/idp/v2/idp_service_pb.js"; +import { RequestContextSchema } from "@zitadel/proto/zitadel/object/v2/object_pb.js"; +import { OIDCService } from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb.js"; +import { OrganizationService } from "@zitadel/proto/zitadel/org/v2/org_service_pb.js"; +import { SAMLService } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb.js"; +import { SessionService } from "@zitadel/proto/zitadel/session/v2/session_service_pb.js"; +import { SettingsService } from "@zitadel/proto/zitadel/settings/v2/settings_service_pb.js"; +import { UserService } from "@zitadel/proto/zitadel/user/v2/user_service_pb.js"; + +import { createClientFor } from "./helpers.js"; + +export const createUserServiceClient = createClientFor(UserService); +export const createSettingsServiceClient = createClientFor(SettingsService); +export const createSessionServiceClient = createClientFor(SessionService); +export const createOIDCServiceClient = createClientFor(OIDCService); +export const createSAMLServiceClient = createClientFor(SAMLService); +export const createOrganizationServiceClient = createClientFor(OrganizationService); +export const createFeatureServiceClient = createClientFor(FeatureService); +export const createIdpServiceClient = createClientFor(IdentityProviderService); + +export function makeReqCtx(orgId: string | undefined) { + return create(RequestContextSchema, { + resourceOwner: orgId ? { case: "orgId", value: orgId } : { case: "instance", value: true }, + }); +} diff --git a/login/packages/zitadel-client/src/v3alpha.ts b/login/packages/zitadel-client/src/v3alpha.ts new file mode 100644 index 0000000000..a5cc533ade --- /dev/null +++ b/login/packages/zitadel-client/src/v3alpha.ts @@ -0,0 +1,6 @@ +import { ZITADELUsers } from "@zitadel/proto/zitadel/resources/user/v3alpha/user_service_pb.js"; +import { ZITADELUserSchemas } from "@zitadel/proto/zitadel/resources/userschema/v3alpha/user_schema_service_pb.js"; +import { createClientFor } from "./helpers.js"; + +export const createUserSchemaServiceClient = createClientFor(ZITADELUserSchemas); +export const createUserServiceClient = createClientFor(ZITADELUsers); diff --git a/login/packages/zitadel-client/src/web.ts b/login/packages/zitadel-client/src/web.ts new file mode 100644 index 0000000000..26c40da0fe --- /dev/null +++ b/login/packages/zitadel-client/src/web.ts @@ -0,0 +1,15 @@ +import { GrpcTransportOptions } from "@connectrpc/connect-node"; +import { createGrpcWebTransport } from "@connectrpc/connect-web"; +import { NewAuthorizationBearerInterceptor } from "./interceptors.js"; + +/** + * Create a client transport using grpc web with the given token and configuration options. + * @param token + * @param opts + */ +export function createClientTransport(token: string, opts: GrpcTransportOptions) { + return createGrpcWebTransport({ + ...opts, + interceptors: [...(opts.interceptors || []), NewAuthorizationBearerInterceptor(token)], + }); +} diff --git a/login/packages/zitadel-client/tsconfig.json b/login/packages/zitadel-client/tsconfig.json new file mode 100644 index 0000000000..5f0ea69110 --- /dev/null +++ b/login/packages/zitadel-client/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@zitadel/tsconfig/tsup.json", + "include": ["./src/**/*"], + "exclude": ["dist", "build", "node_modules"] +} diff --git a/login/packages/zitadel-client/tsup.config.ts b/login/packages/zitadel-client/tsup.config.ts new file mode 100644 index 0000000000..3c9eeb8b83 --- /dev/null +++ b/login/packages/zitadel-client/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, Options } from "tsup"; + +export default defineConfig((options: Options) => ({ + entry: ["src/index.ts", "src/v1.ts", "src/v2.ts", "src/v3alpha.ts", "src/node.ts", "src/web.ts"], + format: ["esm", "cjs"], + treeshake: false, + splitting: true, + dts: true, + minify: false, + clean: true, + sourcemap: true, + ...options, +})); diff --git a/login/packages/zitadel-client/turbo.json b/login/packages/zitadel-client/turbo.json new file mode 100644 index 0000000000..b54d25e2ba --- /dev/null +++ b/login/packages/zitadel-client/turbo.json @@ -0,0 +1,12 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "outputs": ["dist/**"], + "dependsOn": ["@zitadel/proto#generate"] + }, + "build:client:standalone": { + "outputs": ["dist/**"] + } + } +} diff --git a/login/packages/zitadel-eslint-config/CHANGELOG.md b/login/packages/zitadel-eslint-config/CHANGELOG.md new file mode 100644 index 0000000000..6759f857ff --- /dev/null +++ b/login/packages/zitadel-eslint-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/eslint-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-eslint-config/README.md b/login/packages/zitadel-eslint-config/README.md new file mode 100644 index 0000000000..d8d6851f91 --- /dev/null +++ b/login/packages/zitadel-eslint-config/README.md @@ -0,0 +1,35 @@ +# ZITADEL ESLint Config + +This package provides the ESLint configuration used by ZITADEL projects. It includes a set of rules and plugins to ensure consistent code quality and style across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/eslint-config +``` + +or + +```sh +yarn add @zitadel/eslint-config +``` + +## Usage + +To use the ESLint configuration in your project, extend it in your `.eslintrc` file: + +```js +{ + "extends": "@zitadel/eslint-config" +} +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [ESLint documentation](https://eslint.org/docs/user-guide/configuring). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-eslint-config/index.js b/login/packages/zitadel-eslint-config/index.js new file mode 100644 index 0000000000..6a53b2a5e6 --- /dev/null +++ b/login/packages/zitadel-eslint-config/index.js @@ -0,0 +1,13 @@ +module.exports = { + parser: "@babel/eslint-parser", + extends: ["next", "turbo", "prettier"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + parserOptions: { + requireConfigFile: false, + babelOptions: { + presets: ["next/babel"], + }, + }, +}; diff --git a/login/packages/zitadel-eslint-config/package.json b/login/packages/zitadel-eslint-config/package.json new file mode 100644 index 0000000000..84c9c76dab --- /dev/null +++ b/login/packages/zitadel-eslint-config/package.json @@ -0,0 +1,17 @@ +{ + "name": "@zitadel/eslint-config", + "version": "0.1.1", + "main": "index.js", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@typescript-eslint/parser": "^7.9.0", + "eslint-config-next": "^14.2.18", + "eslint-config-prettier": "^9.1.0", + "eslint-config-turbo": "^2.0.9", + "eslint-plugin-react": "^7.34.1", + "@babel/eslint-parser": "^7.25.9" + } +} diff --git a/login/packages/zitadel-prettier-config/CHANGELOG.md b/login/packages/zitadel-prettier-config/CHANGELOG.md new file mode 100644 index 0000000000..83045c3320 --- /dev/null +++ b/login/packages/zitadel-prettier-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/prettier-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-prettier-config/README.md b/login/packages/zitadel-prettier-config/README.md new file mode 100644 index 0000000000..f33913273b --- /dev/null +++ b/login/packages/zitadel-prettier-config/README.md @@ -0,0 +1,36 @@ +# ZITADEL Prettier Config + +This package provides the Prettier configuration used by ZITADEL projects. It includes a set of formatting rules to ensure consistent code style across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/prettier-config +``` + +or + +```sh +yarn add @zitadel/prettier-config +``` + +## Usage + +To use the Prettier configuration in your project, extend it in your `prettier.config.js` file: + +```js +module.exports = { + ...require("@zitadel/prettier-config"), + // Add your custom configurations here +}; +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [Prettier documentation](https://prettier.io/docs/en/configuration.html). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-prettier-config/index.js b/login/packages/zitadel-prettier-config/index.js new file mode 100644 index 0000000000..31d8c455e8 --- /dev/null +++ b/login/packages/zitadel-prettier-config/index.js @@ -0,0 +1,11 @@ +export default { + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: false, + trailingComma: 'all', + bracketSpacing: true, + arrowParens: 'always', + plugins: ["prettier-plugin-organize-imports"] +}; diff --git a/login/packages/zitadel-prettier-config/package.json b/login/packages/zitadel-prettier-config/package.json new file mode 100644 index 0000000000..7b7cbf253e --- /dev/null +++ b/login/packages/zitadel-prettier-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zitadel/prettier-config", + "version": "0.1.1", + "description": "Prettier configuration", + "type": "module", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": "./index.js" + } +} diff --git a/login/packages/zitadel-proto/.gitignore b/login/packages/zitadel-proto/.gitignore new file mode 100644 index 0000000000..20bdea6767 --- /dev/null +++ b/login/packages/zitadel-proto/.gitignore @@ -0,0 +1,5 @@ +zitadel +google +protoc-gen-openapiv2 +validate +node_modules diff --git a/login/packages/zitadel-proto/CHANGELOG.md b/login/packages/zitadel-proto/CHANGELOG.md new file mode 100644 index 0000000000..c3964e2b29 --- /dev/null +++ b/login/packages/zitadel-proto/CHANGELOG.md @@ -0,0 +1,47 @@ +# @zitadel/proto + +## 1.2.0 + +### Minor Changes + +- 62ad388: revert CJS support + +## 1.1.0 + +### Minor Changes + +- 9692297: add CJS and ESM support + +## 1.0.4 + +### Patch Changes + +- 97b0332: bind @zitadel/proto version to zitadel tag + +## 1.0.3 + +### Patch Changes + +- 90fbdd1: use node16/nodenext module resolution + +## 1.0.2 + +### Patch Changes + +- include validate, google and protoc-gen-openapiv2 + +## 1.0.1 + +### Patch Changes + +- README updates + +## 1.0.0 + +### Major Changes + +- 32e1199: Initial Release + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-proto/README.md b/login/packages/zitadel-proto/README.md new file mode 100644 index 0000000000..bf8a064c12 --- /dev/null +++ b/login/packages/zitadel-proto/README.md @@ -0,0 +1,35 @@ +# ZITADEL Proto + +This package provides the Protocol Buffers (proto) definitions used by ZITADEL projects. It includes the proto files and generated code for interacting with ZITADEL's gRPC APIs. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/proto +``` + +or + +```sh +yarn add @zitadel/proto +``` + +## Usage + +To use the proto definitions in your project, import the generated code: + +```ts +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; + +const org: Organization | null = await getDefaultOrg(); +``` + +## Documentation + +For detailed documentation and API references, please visit the [ZITADEL documentation](https://zitadel.com/docs). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-proto/buf.gen.yaml b/login/packages/zitadel-proto/buf.gen.yaml new file mode 100644 index 0000000000..84ecfaea9d --- /dev/null +++ b/login/packages/zitadel-proto/buf.gen.yaml @@ -0,0 +1,10 @@ +version: v2 +managed: + enabled: true +plugins: + - remote: buf.build/bufbuild/es:v2.2.0 + out: . + include_imports: true + opt: + - json_types=true + - import_extension=js diff --git a/login/packages/zitadel-proto/package.json b/login/packages/zitadel-proto/package.json new file mode 100644 index 0000000000..2c60bced4b --- /dev/null +++ b/login/packages/zitadel-proto/package.json @@ -0,0 +1,26 @@ +{ + "name": "@zitadel/proto", + "version": "1.2.0", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "type": "module", + "files": [ + "zitadel/**", + "validate/**", + "google/**", + "protoc-gen-openapiv2/**" + ], + "sideEffects": false, + "scripts": { + "generate": "pnpm exec buf generate https://github.com/zitadel/zitadel.git --path ./proto/zitadel", + "clean": "rm -rf zitadel .turbo node_modules google protoc-gen-openapiv2 validate" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.2.2" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0" + } +} diff --git a/login/packages/zitadel-proto/turbo.json b/login/packages/zitadel-proto/turbo.json new file mode 100644 index 0000000000..2d24f0349b --- /dev/null +++ b/login/packages/zitadel-proto/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "generate": { + "outputs": ["zitadel/**"], + "cache": false + } + } +} diff --git a/login/packages/zitadel-tailwind-config/CHANGELOG.md b/login/packages/zitadel-tailwind-config/CHANGELOG.md new file mode 100644 index 0000000000..e3c4d7207e --- /dev/null +++ b/login/packages/zitadel-tailwind-config/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/tailwind-config + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-tailwind-config/README.md b/login/packages/zitadel-tailwind-config/README.md new file mode 100644 index 0000000000..f52b6c263b --- /dev/null +++ b/login/packages/zitadel-tailwind-config/README.md @@ -0,0 +1,36 @@ +# ZITADEL Tailwind Config + +This package provides the Tailwind CSS configuration used by ZITADEL projects. It includes a set of default styles, themes, and utility classes to ensure consistent design and styling across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/tailwind-config +``` + +or + +```sh +yarn add @zitadel/tailwind-config +``` + +## Usage + +To use the Tailwind CSS configuration in your project, extend it in your `tailwind.config.js` file: + +```js +module.exports = { + presets: [require("@zitadel/tailwind-config")], + // Add your custom configurations here +}; +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [Tailwind CSS documentation](https://tailwindcss.com/docs) + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-tailwind-config/package.json b/login/packages/zitadel-tailwind-config/package.json new file mode 100644 index 0000000000..8fba4bac95 --- /dev/null +++ b/login/packages/zitadel-tailwind-config/package.json @@ -0,0 +1,12 @@ +{ + "name": "@zitadel/tailwind-config", + "version": "0.1.1", + "publishConfig": { + "access": "public" + }, + "main": "index.js", + "devDependencies": { + "tailwindcss": "^4.1.4", + "@tailwindcss/forms": "0.5.3" + } +} diff --git a/login/packages/zitadel-tailwind-config/tailwind.config.mjs b/login/packages/zitadel-tailwind-config/tailwind.config.mjs new file mode 100644 index 0000000000..4a9a437cb7 --- /dev/null +++ b/login/packages/zitadel-tailwind-config/tailwind.config.mjs @@ -0,0 +1,97 @@ +import colors from "tailwindcss/colors"; + +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./app/**/*.{js,ts,jsx,tsx}", "./page/**/*.{js,ts,jsx,tsx}", "./ui/**/*.{js,ts,jsx,tsx}"], + future: { + hoverOnlyWhenSupported: true, + }, + theme: { + extend: { + // https://vercel.com/design/color + fontSize: { + "12px": "12px", + "14px": "14px", + }, + colors: { + gray: colors.zinc, + divider: { + dark: "rgba(135,149,161,.2)", + light: "rgba(135,149,161,.2)", + }, + input: { + light: { + label: "#000000c7", + background: "#00000004", + border: "#1a191954", + hoverborder: "1a1b1b", + }, + dark: { + label: "#ffffffc7", + background: "#00000020", + border: "#f9f7f775", + hoverborder: "#e0e0e0", + }, + }, + button: { + light: { + border: "#0000001f", + }, + dark: { + border: "#ffffff1f", + }, + }, + }, + backgroundImage: ({ theme }) => ({ + "dark-vc-border-gradient": `radial-gradient(at left top, ${theme( + "colors.gray.800", + )}, 50px, ${theme("colors.gray.800")} 50%)`, + "vc-border-gradient": `radial-gradient(at left top, ${theme( + "colors.gray.200", + )}, 50px, ${theme("colors.gray.300")} 50%)`, + }), + keyframes: ({ theme }) => ({ + rerender: { + "0%": { + ["border-color"]: theme("colors.pink.500"), + }, + "40%": { + ["border-color"]: theme("colors.pink.500"), + }, + }, + highlight: { + "0%": { + background: theme("colors.pink.500"), + color: theme("colors.white"), + }, + "40%": { + background: theme("colors.pink.500"), + color: theme("colors.white"), + }, + }, + shimmer: { + "100%": { + transform: "translateX(100%)", + }, + }, + translateXReset: { + "100%": { + transform: "translateX(0)", + }, + }, + fadeToTransparent: { + "0%": { + opacity: 1, + }, + "40%": { + opacity: 1, + }, + "100%": { + opacity: 0, + }, + }, + }), + }, + }, + plugins: [require("@tailwindcss/forms")], +}; diff --git a/login/packages/zitadel-tsconfig/CHANGELOG.md b/login/packages/zitadel-tsconfig/CHANGELOG.md new file mode 100644 index 0000000000..c2d2e7e31c --- /dev/null +++ b/login/packages/zitadel-tsconfig/CHANGELOG.md @@ -0,0 +1,13 @@ +# @zitadel/tsconfig + +## 0.1.1 + +### Patch Changes + +- README updates + +## 0.1.0 + +### Minor Changes + +- f32ab7f: Initial release diff --git a/login/packages/zitadel-tsconfig/README.md b/login/packages/zitadel-tsconfig/README.md new file mode 100644 index 0000000000..b93674b2b1 --- /dev/null +++ b/login/packages/zitadel-tsconfig/README.md @@ -0,0 +1,35 @@ +# ZITADEL TypeScript Config + +This package provides the TypeScript configuration used by ZITADEL projects. It includes a set of rules and settings to ensure consistent TypeScript configuration across all ZITADEL codebases. + +## Installation + +To install the package, use npm or yarn: + +```sh +npm install @zitadel/tsconfig +``` + +or + +```sh +yarn add @zitadel/tsconfig +``` + +## Usage + +To use the TypeScript configuration in your project, extend it in your `tsconfig.json` file: + +```json +{ + "extends": "@zitadel/tsconfig/tsup.json" +} +``` + +## Documentation + +For detailed documentation and configuration options, please refer to the [TypeScript documentation](https://www.typescriptlang.org/docs/). + +## Contributing + +Contributions are welcome! Please read the contributing guidelines before getting started. diff --git a/login/packages/zitadel-tsconfig/base.json b/login/packages/zitadel-tsconfig/base.json new file mode 100644 index 0000000000..6d65860cce --- /dev/null +++ b/login/packages/zitadel-tsconfig/base.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Default", + "compilerOptions": { + "composite": false, + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "inlineSources": false, + "isolatedModules": true, + "moduleResolution": "node16", + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "skipLibCheck": true, + "strict": true + }, + "exclude": ["node_modules"] +} diff --git a/login/packages/zitadel-tsconfig/nextjs.json b/login/packages/zitadel-tsconfig/nextjs.json new file mode 100644 index 0000000000..eaa9942e1e --- /dev/null +++ b/login/packages/zitadel-tsconfig/nextjs.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Next.js", + "extends": "./base.json", + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "preserveSymlinks": true, + "declaration": false, + "declarationMap": false, + "baseUrl": ".", + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["src", "next-env.d.ts"], + "exclude": ["node_modules"] +} diff --git a/login/packages/zitadel-tsconfig/node20.json b/login/packages/zitadel-tsconfig/node20.json new file mode 100644 index 0000000000..bc88cfbee4 --- /dev/null +++ b/login/packages/zitadel-tsconfig/node20.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "Node 20", + "extends": "./base.json", + "compilerOptions": { + "lib": ["es2023"], + "module": "node16", + "target": "es2022" + } +} diff --git a/login/packages/zitadel-tsconfig/package.json b/login/packages/zitadel-tsconfig/package.json new file mode 100644 index 0000000000..a4d713db85 --- /dev/null +++ b/login/packages/zitadel-tsconfig/package.json @@ -0,0 +1,9 @@ +{ + "name": "@zitadel/tsconfig", + "version": "0.1.1", + "publishConfig": { + "access": "public" + }, + "type": "module", + "license": "MIT" +} diff --git a/login/packages/zitadel-tsconfig/react-library.json b/login/packages/zitadel-tsconfig/react-library.json new file mode 100644 index 0000000000..3f6e3580f4 --- /dev/null +++ b/login/packages/zitadel-tsconfig/react-library.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "React Library", + "extends": "./base.json", + "compilerOptions": { + "jsx": "react-jsx", + "lib": ["dom", "ES2015"], + "module": "preserve", + "moduleResolution": "Bundler" + } +} diff --git a/login/packages/zitadel-tsconfig/tsup.json b/login/packages/zitadel-tsconfig/tsup.json new file mode 100644 index 0000000000..1e5cbe42be --- /dev/null +++ b/login/packages/zitadel-tsconfig/tsup.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "display": "tsup", + "extends": "./node20.json" +} diff --git a/login/pnpm-lock.yaml b/login/pnpm-lock.yaml new file mode 100644 index 0000000000..8c6424ccf6 --- /dev/null +++ b/login/pnpm-lock.yaml @@ -0,0 +1,9519 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + '@typescript-eslint/parser': ^7.9.0 + +importers: + + .: + devDependencies: + '@changesets/cli': + specifier: ^2.29.2 + version: 2.29.2 + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.4.1(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + '@zitadel/eslint-config': + specifier: workspace:* + version: link:packages/zitadel-eslint-config + '@zitadel/prettier-config': + specifier: workspace:* + version: link:packages/zitadel-prettier-config + axios: + specifier: ^1.8.4 + version: 1.8.4(debug@4.4.0) + dotenv: + specifier: ^16.5.0 + version: 16.5.0 + dotenv-cli: + specifier: ^8.0.0 + version: 8.0.0 + eslint: + specifier: 8.57.1 + version: 8.57.1 + prettier: + specifier: ^3.5.3 + version: 3.5.3 + prettier-plugin-organize-imports: + specifier: ^4.1.0 + version: 4.1.0(prettier@3.5.3)(typescript@5.8.3) + tsup: + specifier: ^8.4.0 + version: 8.4.0(jiti@1.21.6)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.7.1) + turbo: + specifier: 2.5.0 + version: 2.5.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vite-tsconfig-paths: + specifier: ^5.1.4 + version: 5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + vitest: + specifier: ^3.1.2 + version: 3.1.2(@types/node@22.14.1)(jiti@1.21.6)(jsdom@26.1.0)(sass@1.87.0)(yaml@2.7.1) + + apps/login: + dependencies: + '@headlessui/react': + specifier: ^2.1.9 + version: 2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@heroicons/react': + specifier: 2.1.3 + version: 2.1.3(react@19.1.0) + '@tailwindcss/forms': + specifier: 0.5.7 + version: 0.5.7(tailwindcss@3.4.14) + '@vercel/analytics': + specifier: ^1.2.2 + version: 1.3.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + '@zitadel/client': + specifier: workspace:* + version: link:../../packages/zitadel-client + '@zitadel/proto': + specifier: workspace:* + version: link:../../packages/zitadel-proto + clsx: + specifier: 1.2.1 + version: 1.2.1 + copy-to-clipboard: + specifier: ^3.3.3 + version: 3.3.3 + deepmerge: + specifier: ^4.3.1 + version: 4.3.1 + lucide-react: + specifier: 0.469.0 + version: 0.469.0(react@19.1.0) + moment: + specifier: ^2.29.4 + version: 2.30.1 + next: + specifier: 15.4.0-canary.86 + version: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + next-intl: + specifier: ^3.25.1 + version: 3.26.5(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0) + next-themes: + specifier: ^0.2.1 + version: 0.2.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + nice-grpc: + specifier: 2.0.1 + version: 2.0.1 + qrcode.react: + specifier: ^3.1.0 + version: 3.1.0(react@19.1.0) + react: + specifier: 19.1.0 + version: 19.1.0 + react-dom: + specifier: 19.1.0 + version: 19.1.0(react@19.1.0) + react-hook-form: + specifier: 7.39.5 + version: 7.39.5(react@19.1.0) + tinycolor2: + specifier: 1.4.2 + version: 1.4.2 + uuid: + specifier: ^11.1.0 + version: 11.1.0 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@types/ms': + specifier: 2.1.0 + version: 2.1.0 + '@types/node': + specifier: ^22.14.1 + version: 22.14.1 + '@types/react': + specifier: 19.1.2 + version: 19.1.2 + '@types/react-dom': + specifier: 19.1.2 + version: 19.1.2(@types/react@19.1.2) + '@types/tinycolor2': + specifier: 1.4.3 + version: 1.4.3 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@vercel/git-hooks': + specifier: 1.0.0 + version: 1.0.0 + '@zitadel/eslint-config': + specifier: workspace:* + version: link:../../packages/zitadel-eslint-config + '@zitadel/prettier-config': + specifier: workspace:* + version: link:../../packages/zitadel-prettier-config + '@zitadel/tailwind-config': + specifier: workspace:* + version: link:../../packages/zitadel-tailwind-config + '@zitadel/tsconfig': + specifier: workspace:* + version: link:../../packages/zitadel-tsconfig + autoprefixer: + specifier: 10.4.21 + version: 10.4.21(postcss@8.5.3) + grpc-tools: + specifier: 1.13.0 + version: 1.13.0 + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + lint-staged: + specifier: 15.5.1 + version: 15.5.1 + make-dir-cli: + specifier: 4.0.0 + version: 4.0.0 + postcss: + specifier: 8.5.3 + version: 8.5.3 + prettier-plugin-tailwindcss: + specifier: 0.6.11 + version: 0.6.11(prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3))(prettier@3.5.3) + sass: + specifier: ^1.87.0 + version: 1.87.0 + tailwindcss: + specifier: 3.4.14 + version: 3.4.14 + ts-proto: + specifier: ^2.7.0 + version: 2.7.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + apps/login-test-acceptance: + devDependencies: + '@faker-js/faker': + specifier: ^9.7.0 + version: 9.7.0 + '@otplib/core': + specifier: ^12.0.0 + version: 12.0.1 + '@otplib/plugin-crypto': + specifier: ^12.0.0 + version: 12.0.1 + '@otplib/plugin-thirty-two': + specifier: ^12.0.0 + version: 12.0.1 + '@playwright/test': + specifier: ^1.52.0 + version: 1.52.0 + gaxios: + specifier: ^7.1.0 + version: 7.1.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + apps/login-test-integration: + devDependencies: + '@types/node': + specifier: ^22.14.1 + version: 22.14.1 + concurrently: + specifier: ^9.1.2 + version: 9.1.2 + cypress: + specifier: ^14.3.2 + version: 14.3.2 + env-cmd: + specifier: ^10.0.0 + version: 10.1.0 + nodemon: + specifier: ^3.1.9 + version: 3.1.9 + start-server-and-test: + specifier: ^2.0.11 + version: 2.0.11 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + + packages/zitadel-client: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.2.2 + version: 2.2.2 + '@connectrpc/connect': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2) + '@connectrpc/connect-node': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)) + '@connectrpc/connect-web': + specifier: ^2.0.0 + version: 2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)) + '@zitadel/proto': + specifier: workspace:* + version: link:../zitadel-proto + jose: + specifier: ^5.3.0 + version: 5.8.0 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + '@bufbuild/protocompile': + specifier: ^0.0.1 + version: 0.0.1(@bufbuild/buf@1.53.0) + '@zitadel/eslint-config': + specifier: workspace:* + version: link:../zitadel-eslint-config + '@zitadel/tsconfig': + specifier: workspace:* + version: link:../zitadel-tsconfig + + packages/zitadel-eslint-config: + dependencies: + '@babel/eslint-parser': + specifier: ^7.25.9 + version: 7.25.9(@babel/core@7.26.10)(eslint@8.57.1) + '@typescript-eslint/parser': + specifier: ^7.9.0 + version: 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint-config-next: + specifier: ^14.2.18 + version: 14.2.18(eslint@8.57.1)(typescript@5.8.3) + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-config-turbo: + specifier: ^2.0.9 + version: 2.1.0(eslint@8.57.1) + eslint-plugin-react: + specifier: ^7.34.1 + version: 7.35.0(eslint@8.57.1) + + packages/zitadel-prettier-config: {} + + packages/zitadel-proto: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.2.2 + version: 2.2.2 + devDependencies: + '@bufbuild/buf': + specifier: ^1.53.0 + version: 1.53.0 + + packages/zitadel-tailwind-config: + devDependencies: + '@tailwindcss/forms': + specifier: 0.5.3 + version: 0.5.3(tailwindcss@4.1.4) + tailwindcss: + specifier: ^4.1.4 + version: 4.1.4 + + packages/zitadel-tsconfig: {} + +packages: + + '@adobe/css-tools@4.4.0': + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@3.1.4': + resolution: {integrity: sha512-SeuBV4rnjpFNjI8HSgKUwteuFdkHwkboq31HWzznuqgySQir+jSTczoWVVL4jvOjKjuH80fMDG0Fvg1Sb+OJsA==} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.26.8': + resolution: {integrity: sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.26.10': + resolution: {integrity: sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==} + engines: {node: '>=6.9.0'} + + '@babel/eslint-parser@7.25.9': + resolution: {integrity: sha512-5UXfgpK0j0Xr/xIdgdLEhOFxaDZ0bRPWJJchRpqOSur/3rZoPbqqki5mm0p4NE2cs28krBEiSM2MB7//afRSQQ==} + engines: {node: ^10.13.0 || ^12.13.0 || >=14.0.0} + peerDependencies: + '@babel/core': ^7.11.0 + eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 + + '@babel/generator@7.27.0': + resolution: {integrity: sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.0': + resolution: {integrity: sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.26.5': + resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.27.0': + resolution: {integrity: sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.25.9': + resolution: {integrity: sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.25.9': + resolution: {integrity: sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.27.0': + resolution: {integrity: sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.0': + resolution: {integrity: sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.27.0': + resolution: {integrity: sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.27.0': + resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} + engines: {node: '>=6.9.0'} + + '@bufbuild/buf-darwin-arm64@1.53.0': + resolution: {integrity: sha512-UVhqDYu54ciiCMeG6RODlrX5XRvLN6PfsVDqMQG0JwmMKtUi326CbUqsqO7xsQbcEUso3FTBaURir4RixoM88w==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@bufbuild/buf-darwin-x64@1.53.0': + resolution: {integrity: sha512-03lKaenjf08HF6DlARPU2lEL2dRxNsU6rb9GbUu+YeLayWy7SUlfeDB8drAZ/GpfSc7SL8TKF7jqRkqxT4wFGA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@bufbuild/buf-linux-aarch64@1.53.0': + resolution: {integrity: sha512-FlxrB+rZJG5u7v2JovzXvSR/OdXjVXYHTTLnk6vN/73KPbpGPzZrW7mKxlYyn/Uar5tKDAYvmijjuItXZ6i31g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@bufbuild/buf-linux-armv7@1.53.0': + resolution: {integrity: sha512-e9ER+5Os1DPLhr2X1BRPrQpDZWpv5Mkk2PLnmmzh5RL4kOueJKQZj/m1qQr7SQkiPPhS0yMw7EEghsr521FFzQ==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@bufbuild/buf-linux-x64@1.53.0': + resolution: {integrity: sha512-LehyZPbkRgCvIM56uUnCAUD1QSno2wkBZ5HOvjrjOd0GEjfKgw/fsEu13fJR13bGBNOeOUHbHrd59iUSyY6rGA==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@bufbuild/buf-win32-arm64@1.53.0': + resolution: {integrity: sha512-QRNMHYW6v4keoelIwMNZGQw2R67fsS8lEDnYxrFmiRADwZ/ri/XKJjvQfpoE2Bq0xREB0zZ++RX+1DZOkTA/Iw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@bufbuild/buf-win32-x64@1.53.0': + resolution: {integrity: sha512-relZlT9gYrZGcEH4dcJhEWrjaHV9drG1PcgW6krqw1AzpQOPxR/loXJ7DycoCAnUhQ9TdsdTfUlVHqiJt98piQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@bufbuild/buf@1.53.0': + resolution: {integrity: sha512-GGAztQbbKSv+HaihdDIUpejUcxIx2Fse9SqHfMisJbL/hZ7aOH7BFeSH0q8/g2kSAsLABlenVKeEWKX1uZU3LQ==} + engines: {node: '>=12'} + hasBin: true + + '@bufbuild/protobuf@2.2.2': + resolution: {integrity: sha512-UNtPCbrwrenpmrXuRwn9jYpPoweNXj8X5sMvYgsqYyaH8jQ6LfUJSk3dJLnBK+6sfYPrF4iAIo5sd5HQ+tg75A==} + + '@bufbuild/protobuf@2.2.5': + resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==} + + '@bufbuild/protocompile@0.0.1': + resolution: {integrity: sha512-cOTMtjcWLcbjF17dPYgeMtVC5jZyS0bSjz3jy8kDPjOgjgSYMD2u2It7w8aCc2z23hTPIKl/2SNdMnz0Jzu3Xg==} + peerDependencies: + '@bufbuild/buf': ^1.22.0 + + '@changesets/apply-release-plan@7.0.12': + resolution: {integrity: sha512-EaET7As5CeuhTzvXTQCRZeBUcisoYPDDcXvgTE/2jmmypKp0RC7LxKj/yzqeh/1qFTZI7oDGFcL1PHRuQuketQ==} + + '@changesets/assemble-release-plan@6.0.6': + resolution: {integrity: sha512-Frkj8hWJ1FRZiY3kzVCKzS0N5mMwWKwmv9vpam7vt8rZjLL1JMthdh6pSDVSPumHPshTTkKZ0VtNbE0cJHZZUg==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.29.2': + resolution: {integrity: sha512-vwDemKjGYMOc0l6WUUTGqyAWH3AmueeyoJa1KmFRtCYiCoY5K3B68ErYpDB6H48T4lLI4czum4IEjh6ildxUeg==} + hasBin: true + + '@changesets/config@3.1.1': + resolution: {integrity: sha512-bd+3Ap2TKXxljCggI0mKPfzCQKeV/TU4yO2h2C6vAihIo8tzseAn2e7klSuiyYYXvgu53zMN1OeYMIQkaQoWnA==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + + '@changesets/get-release-plan@4.0.10': + resolution: {integrity: sha512-CCJ/f3edYaA3MqoEnWvGGuZm0uMEMzNJ97z9hdUR34AOvajSwySwsIzC/bBu3+kuGDsB+cny4FljG8UBWAa7jg==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.1': + resolution: {integrity: sha512-iwksMs5Bf/wUItfcg+OXrEpravm5rEd9Bf4oyIPL4kVTmJQ7PNDSd6MDYkpSJR1pn7tz/k8Zf2DhTCqX08Ou+Q==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.5': + resolution: {integrity: sha512-UPzNGhsSjHD3Veb0xO/MwvasGe8eMyNrR/sT9gR8Q3DhOQZirgKhhXv/8hVsI0QpPjR004Z9iFxoJU6in3uGMg==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@connectrpc/connect-node@2.0.0': + resolution: {integrity: sha512-DoI5T+SUvlS/8QBsxt2iDoUg15dSxqhckegrgZpWOtADtmGohBIVbx1UjtWmjLBrP4RdD0FeBw+XyRUSbpKnJQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0 + + '@connectrpc/connect-web@2.0.0': + resolution: {integrity: sha512-oeCxqHXLXlWJdmcvp9L3scgAuK+FjNSn+twyhUxc8yvDbTumnt5Io+LnBzSYxAdUdYqTw5yHfTSCJ4hj0QID0g==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0 + + '@connectrpc/connect@2.0.0': + resolution: {integrity: sha512-Usm8jgaaULANJU8vVnhWssSA6nrZ4DJEAbkNtXSoZay2YD5fDyMukCxu8NEhCvFzfHvrhxhcjttvgpyhOM7xAQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + + '@csstools/color-helpers@5.0.2': + resolution: {integrity: sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.3': + resolution: {integrity: sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-color-parser@3.0.9': + resolution: {integrity: sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.4 + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-parser-algorithms@3.0.4': + resolution: {integrity: sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.3 + + '@csstools/css-tokenizer@3.0.3': + resolution: {integrity: sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==} + engines: {node: '>=18'} + + '@cypress/request@3.0.8': + resolution: {integrity: sha512-h0NFgh1mJmm1nr4jCwkGHwKneVYKghUyWe6TMNrk0B9zsjAJxpg8C4/+BAcmLgCPa1vj1V8rNUaILl+zYRUWBQ==} + engines: {node: '>= 6'} + + '@cypress/xvfb@1.2.4': + resolution: {integrity: sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==} + + '@emnapi/runtime@1.4.3': + resolution: {integrity: sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==} + + '@esbuild/aix-ppc64@0.25.2': + resolution: {integrity: sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.2': + resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.2': + resolution: {integrity: sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.2': + resolution: {integrity: sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.2': + resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.2': + resolution: {integrity: sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.2': + resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.2': + resolution: {integrity: sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.2': + resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.2': + resolution: {integrity: sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.2': + resolution: {integrity: sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.2': + resolution: {integrity: sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.2': + resolution: {integrity: sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.2': + resolution: {integrity: sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.2': + resolution: {integrity: sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.2': + resolution: {integrity: sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.2': + resolution: {integrity: sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.2': + resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.2': + resolution: {integrity: sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.2': + resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.2': + resolution: {integrity: sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.2': + resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.2': + resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.2': + resolution: {integrity: sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.2': + resolution: {integrity: sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@faker-js/faker@9.7.0': + resolution: {integrity: sha512-aozo5vqjCmDoXLNUJarFZx2IN/GgGaogY4TMJ6so/WLZOWpSV7fvj2dmrV6sEAnUm1O7aCrhTibjpzeDFgNqbg==} + engines: {node: '>=18.0.0', npm: '>=9.0.0'} + + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + + '@floating-ui/dom@1.6.11': + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.24': + resolution: {integrity: sha512-2ly0pCkZIGEQUq5H8bBK0XJmc1xIK/RM3tvVzY3GBER7IOD1UgmC2Y2tjj4AuS+TC+vTE1KJv2053290jua0Sw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + + '@formatjs/ecma402-abstract@2.2.4': + resolution: {integrity: sha512-lFyiQDVvSbQOpU+WFd//ILolGj4UgA/qXrKeZxdV14uKiAUiPAtX6XAn7WBCRi7Mx6I7EybM9E5yYn4BIpZWYg==} + + '@formatjs/fast-memoize@2.2.3': + resolution: {integrity: sha512-3jeJ+HyOfu8osl3GNSL4vVHUuWFXR03Iz9jjgI7RwjG6ysu/Ymdr0JRCPHfF5yGbTE6JCrd63EpvX1/WybYRbA==} + + '@formatjs/icu-messageformat-parser@2.9.4': + resolution: {integrity: sha512-Tbvp5a9IWuxUcpWNIW6GlMQYEc4rwNHR259uUFoKWNN1jM9obf9Ul0e+7r7MvFOBNcN+13K7NuKCKqQiAn1QEg==} + + '@formatjs/icu-skeleton-parser@1.8.8': + resolution: {integrity: sha512-vHwK3piXwamFcx5YQdCdJxUQ1WdTl6ANclt5xba5zLGDv5Bsur7qz8AD7BevaKxITwpgDeU0u8My3AIibW9ywA==} + + '@formatjs/intl-localematcher@0.5.8': + resolution: {integrity: sha512-I+WDNWWJFZie+jkfkiK5Mp4hEDyRSEvmyfYadflOno/mmKJKcB17fEpEH0oJu/OWhhCJ8kJBDz2YMd/6cDl7Mg==} + + '@grpc/grpc-js@1.11.1': + resolution: {integrity: sha512-gyt/WayZrVPH2w/UTLansS7F9Nwld472JxxaETamrM8HNlsa+jSLNyKAZmhxI2Me4c3mQHFiS1wWHDY1g1Kthw==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.13': + resolution: {integrity: sha512-AiXO/bfe9bmxBjxxtYxFAXGZvMaN5s8kO+jBHAJCON8rJoB5YS/D6X7ZNc6XQkuHNmyl4CYaMI1fJ/Gn27RGGw==} + engines: {node: '>=6'} + hasBin: true + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + + '@headlessui/react@2.1.9': + resolution: {integrity: sha512-ckWw7vlKtnoa1fL2X0fx1a3t/Li9MIKDVXn3SgG65YlxvDAsNrY39PPCxVM7sQRA7go2fJsuHSSauKFNaJHH7A==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 + react-dom: ^18 + + '@heroicons/react@2.1.3': + resolution: {integrity: sha512-fEcPfo4oN345SoqdlCDdSa4ivjaKbk0jTd+oubcgNxnNgAfzysfwWfQUr+51wigiWHQQRiZNd1Ao0M5Y3M2EGg==} + peerDependencies: + react: '>= 16' + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@img/sharp-darwin-arm64@0.34.1': + resolution: {integrity: sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.1': + resolution: {integrity: sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.1.0': + resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.1.0': + resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.1.0': + resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.1.0': + resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.1.0': + resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.1.0': + resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.1.0': + resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.1': + resolution: {integrity: sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.1': + resolution: {integrity: sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.34.1': + resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.1': + resolution: {integrity: sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.1': + resolution: {integrity: sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.1': + resolution: {integrity: sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.1': + resolution: {integrity: sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.34.1': + resolution: {integrity: sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.1': + resolution: {integrity: sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + + '@next/env@15.4.0-canary.86': + resolution: {integrity: sha512-WPrEvwqHnjeLx05ncJvqizbBJJFlQGRbxzOnL/pZWKzo19auM9x5Se87P27+E/D/d6jJS801l+thF85lfobAZQ==} + + '@next/eslint-plugin-next@14.2.18': + resolution: {integrity: sha512-KyYTbZ3GQwWOjX3Vi1YcQbekyGP0gdammb7pbmmi25HBUCINzDReyrzCMOJIeZisK1Q3U6DT5Rlc4nm2/pQeXA==} + + '@next/swc-darwin-arm64@15.4.0-canary.86': + resolution: {integrity: sha512-1ofBmzjPkmoMdM+dXvybZ/Roq8HRo0sFzcwXk7/FJNOufuwyK+QKdSpLE7pHlPR7ZREqfEMj61ONO+gAK+zOJw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.4.0-canary.86': + resolution: {integrity: sha512-WCKSrllvwzYi4TgrSdgxKSOF2nhieeaWWOeGucn0OXy50uOAamr0HwP5OaIBCx3oRar4w66gvs4IrdTdMedeJA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.4.0-canary.86': + resolution: {integrity: sha512-8qn7DJVNFjhEIDo2ts0YCsO7g+vJjPWh8Ur8lBK3XspeX0BPsF4s+YmgidrpzRXeIfoo2uYLkkXcy/57CVDblw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.4.0-canary.86': + resolution: {integrity: sha512-8MTn6N4Ja25neMLu2Bra1lqW9AWPqsYg0BVs5M/cxL0QkcN3mak/8LLX1vbzz7GigMGSA+NLwg+ol8lglfgIGA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.4.0-canary.86': + resolution: {integrity: sha512-hIhzDwWDQHnH0M0Pzaqs1c5fa4+LHiLLEBuPJQvhBxQfH+Eh86DWiWHDCaoNiURvdRPg6uCuF2MjwptrMplEkg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.4.0-canary.86': + resolution: {integrity: sha512-FG6SBuSeRWYMNu6tsfaZ4iDzv3BLxlpRncO2xvKKQPeUdDSQ0cehuHYnx8fRte8IOAJ3rlbRd6NXvrDarqu92Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.4.0-canary.86': + resolution: {integrity: sha512-3HvZo4VuyINrNYplRhvC8ILdKwi/vFDHOcTN/I4ru039TFpu2eO6VtXsLBdOdJjGslSSSBYkX+6yRrghihAZDA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.4.0-canary.86': + resolution: {integrity: sha512-UO9JzGGj7GhtSJFdI0Bl0dkIIBfgbhXLsgNVmq9Z/CsUsQB6J9RS/BMhsxfVwhO+RETk13nFpNutMAhAwcuD8w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@otplib/core@12.0.1': + resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} + + '@otplib/plugin-crypto@12.0.1': + resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} + + '@otplib/plugin-thirty-two@12.0.1': + resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.52.0': + resolution: {integrity: sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==} + engines: {node: '>=18'} + hasBin: true + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + + '@react-aria/focus@3.18.3': + resolution: {integrity: sha512-WKUElg+5zS0D3xlVn8MntNnkzJql2J6MuzAMP8Sv5WTgFDse/XGR842dsxPTIyKKdrWVCRegCuwa4m3n/GzgJw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/interactions@3.22.3': + resolution: {integrity: sha512-RRUb/aG+P0IKTIWikY/SylB6bIbLZeztnZY2vbe7RAG5MgVaCgn5HQ45SI15GlTmhsFG8CnF6slJsUFJiNHpbQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/ssr@3.9.6': + resolution: {integrity: sha512-iLo82l82ilMiVGy342SELjshuWottlb5+VefO3jOQqQRNYnJBFpUSadswDPbRimSgJUZuFwIEYs6AabkP038fA==} + engines: {node: '>= 12'} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-aria/utils@3.25.3': + resolution: {integrity: sha512-PR5H/2vaD8fSq0H/UB9inNbc8KDcVmW6fYAfSWkkn+OAdhTTMVKqXXrZuZBWyFfSD5Ze7VN6acr4hrOQm2bmrA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-stately/utils@3.10.4': + resolution: {integrity: sha512-gBEQEIMRh5f60KCm7QKQ2WfvhB2gLUr9b72sqUdIZ2EG+xuPgaIlCBeSicvjmjBvYZwOjoOEnmIkcx2GHp/HWw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@react-types/shared@3.25.0': + resolution: {integrity: sha512-OZSyhzU6vTdW3eV/mz5i6hQwQUhkRs7xwY2d1aqPvTdMe0+2cY7Fwp45PAiwYLEj73i9ro2FxF9qC4DvHGSCgQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0 + + '@rollup/rollup-android-arm-eabi@4.40.0': + resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.40.0': + resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.40.0': + resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.40.0': + resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.40.0': + resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.40.0': + resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.40.0': + resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.40.0': + resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.40.0': + resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.40.0': + resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==} + cpu: [x64] + os: [win32] + + '@rushstack/eslint-patch@1.10.4': + resolution: {integrity: sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==} + + '@sideway/address@4.1.5': + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + + '@sideway/formula@3.0.1': + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + + '@sideway/pinpoint@2.0.0': + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@swc/helpers@0.5.5': + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} + + '@tailwindcss/forms@0.5.3': + resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + + '@tailwindcss/forms@0.5.7': + resolution: {integrity: sha512-QE7X69iQI+ZXwldE+rzasvbJiyV/ju1FGHH0Qn2W3FKbuYtqp8LKcy6iSw79fVUT5/Vvf+0XgLCeYVG+UV6hOw==} + peerDependencies: + tailwindcss: '>=3.0.0 || >= 3.0.0-alpha.1' + + '@tanstack/react-virtual@3.10.6': + resolution: {integrity: sha512-xaSy6uUxB92O8mngHZ6CvbhGuqxQ5lIZWCBy+FjhrbHmOwc6BnOnKkYm2FsB1/BpKw/+FVctlMbEtI+F6I1aJg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + + '@tanstack/virtual-core@3.10.6': + resolution: {integrity: sha512-1giLc4dzgEKLMx5pgKjL6HlG5fjZMgCjzlKAlpr7yoUtetVPELgER1NtephAI910nMwfPTHNyWKSFmJdHkz2Cw==} + + '@testing-library/dom@10.4.0': + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.0': + resolution: {integrity: sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@22.14.1': + resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} + + '@types/react-dom@19.1.2': + resolution: {integrity: sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react@19.1.2': + resolution: {integrity: sha512-oxLPMytKchWGbnQM9O7D67uPa9paTNxO7jVoNMXgkkErULBPhPARCfkKL9ytcIJJRGjbsVwW4ugJzyFFvm/Tiw==} + + '@types/sinonjs__fake-timers@8.1.1': + resolution: {integrity: sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==} + + '@types/sizzle@2.3.9': + resolution: {integrity: sha512-xzLEyKB50yqCUPUJkIsrVvoWNfFUbIZI+RspLWt8u+tIW/BetMBZtgV2LY/2o+tYH8dRvQ+eoPf3NdhQCcLE2w==} + + '@types/tinycolor2@1.4.3': + resolution: {integrity: sha512-Kf1w9NE5HEgGxCRyIcRXR/ZYtDv0V8FVPtYHwLxl0O+maGX0erE77pQlD0gpP+/KByMZ87mOA79SjifhSB3PjQ==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.15.0': + resolution: {integrity: sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^7.9.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/scope-manager@8.15.0': + resolution: {integrity: sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.15.0': + resolution: {integrity: sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/types@8.15.0': + resolution: {integrity: sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/typescript-estree@8.15.0': + resolution: {integrity: sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@8.15.0': + resolution: {integrity: sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + + '@typescript-eslint/visitor-keys@8.15.0': + resolution: {integrity: sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.2.0': + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + + '@vercel/analytics@1.3.1': + resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==} + peerDependencies: + next: '>= 13' + react: ^18 || ^19 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + + '@vercel/git-hooks@1.0.0': + resolution: {integrity: sha512-OxDFAAdyiJ/H0b8zR9rFCu3BIb78LekBXOphOYG3snV4ULhKFX387pBPpqZ9HLiRTejBWBxYEahkw79tuIgdAA==} + + '@vitejs/plugin-react@4.4.1': + resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + '@vitest/expect@3.1.2': + resolution: {integrity: sha512-O8hJgr+zREopCAqWl3uCVaOdqJwZ9qaDwUP7vy3Xigad0phZe9APxKhPcDNqYYi0rX5oMvwJMSCAXY2afqeTSA==} + + '@vitest/mocker@3.1.2': + resolution: {integrity: sha512-kOtd6K2lc7SQ0mBqYv/wdGedlqPdM/B38paPY+OwJ1XiNi44w3Fpog82UfOibmHaV9Wod18A09I9SCKLyDMqgw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.1.2': + resolution: {integrity: sha512-R0xAiHuWeDjTSB3kQ3OQpT8Rx3yhdOAIm/JM4axXxnG7Q/fS8XUwggv/A4xzbQA+drYRjzkMnpYnOGAc4oeq8w==} + + '@vitest/runner@3.1.2': + resolution: {integrity: sha512-bhLib9l4xb4sUMPXnThbnhX2Yi8OutBMA8Yahxa7yavQsFDtwY/jrUZwpKp2XH9DhRFJIeytlyGpXCqZ65nR+g==} + + '@vitest/snapshot@3.1.2': + resolution: {integrity: sha512-Q1qkpazSF/p4ApZg1vfZSQ5Yw6OCQxVMVrLjslbLFA1hMDrT2uxtqMaw8Tc/jy5DLka1sNs1Y7rBcftMiaSH/Q==} + + '@vitest/spy@3.1.2': + resolution: {integrity: sha512-OEc5fSXMws6sHVe4kOFyDSj/+4MSwst0ib4un0DlcYgQvRuYQ0+M2HyqGaauUMnjq87tmUaMNDxKQx7wNfVqPA==} + + '@vitest/utils@3.1.2': + resolution: {integrity: sha512-5GGd0ytZ7BH3H6JTj9Kw7Prn1Nbg0wZVrIvou+UWxm54d+WoXXgAgjFJ8wn3LdagWLFSEfpPeyYrByZaGEZHLg==} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + abort-controller-x@0.4.3: + resolution: {integrity: sha512-VtUwTNU8fpMwvWGn4xE93ywbogTYsuT+AUxAXOeelbXuQVIwNmC5YLeho9sH4vZ4ITW8414TTAOG1nW6uIVHCA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.0.0: + resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + + array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + aws-sign2@0.7.0: + resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + axe-core@4.10.0: + resolution: {integrity: sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g==} + engines: {node: '>=4'} + + axios@1.8.4: + resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==} + + axobject-query@3.1.1: + resolution: {integrity: sha512-goKlv8DZrK9hUh975fnHzhNIO4jUnFCfv/dszV5VwUGDFjI6vQ2VwoyjYjYNEbBE8AH87TduWP5uyDR1D+Iteg==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + blob-util@2.0.2: + resolution: {integrity: sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cachedir@2.4.0: + resolution: {integrity: sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==} + engines: {node: '>=6'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001715: + resolution: {integrity: sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==} + + case-anything@2.1.13: + resolution: {integrity: sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==} + engines: {node: '>=12.13'} + + caseless@0.12.0: + resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} + + chai@5.2.0: + resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} + engines: {node: '>=12'} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + + check-more-types@2.24.0: + resolution: {integrity: sha512-Pj779qHxV2tuapviy1bSZNEL1maXr13bPYpsvSDB68HlYcYuhlDrmGd63i0JHMCLKzc7rUSNIrpdJlhVlNwrxA==} + engines: {node: '>= 0.8.0'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + ci-info@4.2.0: + resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cli-truncate@4.0.0: + resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==} + engines: {node: '>=18'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + common-tags@1.8.2: + resolution: {integrity: sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==} + engines: {node: '>=4.0.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concurrently@9.1.2: + resolution: {integrity: sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==} + engines: {node: '>=18'} + hasBin: true + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssstyle@4.3.1: + resolution: {integrity: sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==} + engines: {node: '>=18'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + cypress@14.3.2: + resolution: {integrity: sha512-n+yGD2ZFFKgy7I3YtVpZ7BcFYrrDMcKj713eOZdtxPttpBjCyw/R8dLlFSsJPouneGN7A/HOSRyPJ5+3/gKDoA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + dashdash@1.14.1: + resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} + engines: {node: '>=0.10'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.5.0: + resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dotenv-cli@8.0.0: + resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==} + hasBin: true + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + + dprint-node@1.0.8: + resolution: {integrity: sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer@0.1.2: + resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecc-jsbn@0.1.2: + resolution: {integrity: sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==} + + electron-to-chromium@1.5.140: + resolution: {integrity: sha512-o82Rj+ONp4Ip7Cl1r7lrqx/pXhbp/lh9DpKcMNscFJdh8ebyRofnc7Sh01B4jx403RI0oqTBvlZ7OBIZLMr2+Q==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + + env-cmd@10.1.0: + resolution: {integrity: sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==} + engines: {node: '>=8.0.0'} + hasBin: true + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + + es-iterator-helpers@1.0.19: + resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + + es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.2: + resolution: {integrity: sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@14.2.18: + resolution: {integrity: sha512-SuDRcpJY5VHBkhz5DijJ4iA4bVnBA0n48Rb+YSJSCDr+h7kKAcb1mZHusLbW+WA8LDB6edSolomXA55eG3eOVA==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-config-turbo@2.1.0: + resolution: {integrity: sha512-3SeE2OCWnkA/84adGJXABm++966LNGxRdXtXKBcplJdIe4PmERkov1z6Kzp2PrPKT13wGu/bwoLV5h1rm7v9ug==} + peerDependencies: + eslint: '>6.6.0' + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-import-resolver-typescript@3.6.3: + resolution: {integrity: sha512-ud9aw4szY9cCT1EWWdGv1L1XR6hh2PaRWif0j2QjQ0pgTY/69iw+W0Z4qZv5wHahOl8isEr+k/JnyAqNQkLkIA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.8.2: + resolution: {integrity: sha512-3XnC5fDyc8M4J2E8pt8pmSVRX2M+5yWMCfI/kDZwauQeFgzQOuhcRBFKjTeJagqgk4sFKxe1mvNVnaWwImx/Tg==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.29.1: + resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.9.0: + resolution: {integrity: sha512-nOFOCaJG2pYqORjK19lqPqxMO/JpvdCZdPtNdxY3kvom3jTvkAbOvQvD8wuD0G8BYR0IGAGYDlzqWJOh/ybn2g==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react@7.35.0: + resolution: {integrity: sha512-v501SSMOWv8gerHkk+IIQBkcGRGrO2nfybfj5pLxuJNFTPxxA3PSryhXTK+9pNbtkggheDdsC0E9Q8CuPk6JKA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-plugin-turbo@2.1.0: + resolution: {integrity: sha512-+CWVY29y7Qa+gvrKSzP+TOYrHAlNLCh/97K5VtDdnpH54h/JFmnd3U0aSG6WANe0HgAK8NHQfeWFDdRzfDqbKA==} + peerDependencies: + eslint: '>6.6.0' + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@2.1.0: + resolution: {integrity: sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==} + engines: {node: '>=10'} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + event-stream@3.3.4: + resolution: {integrity: sha512-QHpkERcGsR0T7Qm3HNJSyXKEEj8AHNxkY3PK8TS2KJvQ7NiSHe3DDpwVKKtoYprL/AreyzFBeIkBIWChAqn60g==} + + eventemitter2@6.4.7: + resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + + executable@4.1.1: + resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} + engines: {node: '>=4'} + + expect-type@1.2.1: + resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.3.0: + resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forever-agent@0.6.1: + resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} + + form-data@4.0.2: + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + from@0.1.7: + resolution: {integrity: sha512-twe20eF1OxVxp/ML/kq2p1uc6KvFK/+vs8WjEbeKmV2He22MKm7YF2ANIt+EOqhJ5L3K/SuuPhk0hWQDjOM23g==} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + + gaxios@7.1.0: + resolution: {integrity: sha512-y1Q0MX1Ba6eg67Zz92kW0MHHhdtWksYckQy1KJsI6P4UlDQ8cvdvpLEPslD/k7vFkdPppMESFGTvk7XpSiKj8g==} + engines: {node: '>=18'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + + get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + + get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.8.0: + resolution: {integrity: sha512-Pgba6TExTZ0FJAn1qkJAjIeKoDJ3CsI2ChuLohJnZl/tTU8MVrq3b+2t5UOPfRa4RMsorClBjJALkJUMjG1PAw==} + + getos@3.2.1: + resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} + + getpass@0.1.7: + resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + global-dirs@3.0.1: + resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} + engines: {node: '>=10'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + grpc-tools@1.13.0: + resolution: {integrity: sha512-7CbkJ1yWPfX0nHjbYG58BQThNhbICXBZynzCUxCb3LzX5X9B3hQbRY2STiRgIEiLILlK9fgl0z0QVGwPCdXf5g==} + hasBin: true + + has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http-signature@1.4.0: + resolution: {integrity: sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==} + engines: {node: '>=0.10'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-id@4.1.1: + resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} + hasBin: true + + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + immutable@5.1.1: + resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@2.0.0: + resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} + engines: {node: '>=10'} + + internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + + intl-messageformat@10.7.7: + resolution: {integrity: sha512-F134jIoeYMro/3I0h08D0Yt4N9o9pjddU/4IIxMMURqbAtI2wu70X8hvG1V48W49zXHXv3RKSF/po+0fDfsGjA==} + + is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + + is-async-function@2.0.0: + resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + engines: {node: '>= 0.4'} + + is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + + is-bun-module@1.1.0: + resolution: {integrity: sha512-4mTAVPlrXpaN3jtF0lsnPCMGnq4+qZjVIKq0HCpfcqf8OC1SM5oATCIAPM5V5FN05qp2NNnFndphmdZS9CV3hA==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + + is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-fullwidth-code-point@4.0.0: + resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} + engines: {node: '>=12'} + + is-fullwidth-code-point@5.0.0: + resolution: {integrity: sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==} + engines: {node: '>=18'} + + is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-installed-globally@0.4.0: + resolution: {integrity: sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==} + engines: {node: '>=10'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + + is-typedarray@1.0.0: + resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + + is-weakset@2.0.3: + resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} + engines: {node: '>= 0.4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + iterator.prototype@1.1.2: + resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jiti@1.21.6: + resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} + hasBin: true + + joi@17.13.3: + resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==} + + jose@5.8.0: + resolution: {integrity: sha512-E7CqYpL/t7MMnfGnK/eg416OsFCVUrU/Y3Vwe7QjKhu/BkS1Ms455+2xsqZQVN57/U2MHMBvEb5SrmAZWAIntA==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsbn@0.1.1: + resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema@0.4.0: + resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsprim@2.0.2: + resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} + engines: {'0': node >=0.6.0} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + lazy-ass@1.6.0: + resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==} + engines: {node: '> 0.8'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lint-staged@15.5.1: + resolution: {integrity: sha512-6m7u8mue4Xn6wK6gZvSCQwBvMBR36xfY24nF5bMTf2MHDYG6S3yhJuOgdYVw99hsjyDt2d4z168b3naI8+NWtQ==} + engines: {node: '>=18.12.0'} + hasBin: true + + listr2@3.14.0: + resolution: {integrity: sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==} + engines: {node: '>=10.0.0'} + peerDependencies: + enquirer: '>= 2.3.0 < 3' + peerDependenciesMeta: + enquirer: + optional: true + + listr2@8.3.2: + resolution: {integrity: sha512-vsBzcU4oE+v0lj4FhVLzr9dBTv4/fHIa57l+GCwovP8MoFNZJTOhGU8PXd4v2VJCbECAaijBiHntiekFMLvo0g==} + engines: {node: '>=18.0.0'} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + log-update@4.0.0: + resolution: {integrity: sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==} + engines: {node: '>=10'} + + log-update@6.1.0: + resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} + engines: {node: '>=18'} + + long@5.2.3: + resolution: {integrity: sha512-lcHwpNoggQTObv5apGNCTdJrO69eHOZMi4BNC+rTLER8iHAqGrUVeLh/irVIM7zTw2bOXA8T6uNPeujwOLg/2Q==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.1.3: + resolution: {integrity: sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.469.0: + resolution: {integrity: sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + make-dir-cli@4.0.0: + resolution: {integrity: sha512-9BBC2CaGH0hUAx+tQthgxqYypwkTs+7oXmPdiWyDpHGo4mGB3kdudUKQGivK59C1aJroo4QLlXF7Chu/kdhYiw==} + engines: {node: '>=18'} + hasBin: true + + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + + make-dir@5.0.0: + resolution: {integrity: sha512-G0yBotnlWVonPClw+tq+xi4K7DZC9n96HjGTBDdHkstAVsDkfZhi1sTvZypXLpyQTbISBkDtK0E5XlUqDsShQg==} + engines: {node: '>=18'} + + map-stream@0.1.0: + resolution: {integrity: sha512-CkYQrPYZfWnu/DAmVCpTSX/xHpKZ80eKh2lAkyA6AJTef6bW+6JpbQZN5rofum7da+SyN1bi5ctTm+lTfcCW3g==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-intl@3.26.5: + resolution: {integrity: sha512-EQlCIfY0jOhRldiFxwSXG+ImwkQtDEfQeSOEQp6ieAGSLWGlgjdb/Ck/O7wMfC430ZHGeUKVKax8KGusTPKCgg==} + peerDependencies: + next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + + next-themes@0.2.1: + resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} + peerDependencies: + next: '*' + react: '*' + react-dom: '*' + + next@15.4.0-canary.86: + resolution: {integrity: sha512-lGeO0sOvPZ7oFIklqRA863YzRL1bW+kT/OqU3N6RBquHldiucZwnZKQceZdn6WcHEFmWIHzZV+SMG1JEK7hZLg==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + nice-grpc-common@2.0.2: + resolution: {integrity: sha512-7RNWbls5kAL1QVUOXvBsv1uO0wPQK3lHv+cY1gwkTzirnG1Nop4cBJZubpgziNbaVc/bl9QJcyvsf/NQxa3rjQ==} + + nice-grpc@2.0.1: + resolution: {integrity: sha512-Q5CGXO08STsv+HAkXeFgRayANT62X1LnIDhNXdCf+LP0XaP7EiHM0Cr3QefnoFjDZAx/Kxq+qiQfY66BrtKcNQ==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + nodemon@3.1.9: + resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==} + engines: {node: '>=10'} + hasBin: true + + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + + nwsapi@2.2.20: + resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + + object.entries@1.1.8: + resolution: {integrity: sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + ospath@1.2.2: + resolution: {integrity: sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + playwright-core@1.52.0: + resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.52.0: + resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + engines: {node: '>=18'} + hasBin: true + + possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.0.1: + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-organize-imports@4.1.0: + resolution: {integrity: sha512-5aWRdCgv645xaa58X8lOxzZoiHAldAPChljr/MT0crXVOWTZ+Svl4hIWlz+niYSlO6ikE5UXkN1JrRvIP2ut0A==} + peerDependencies: + prettier: '>=2.0' + typescript: '>=2.9' + vue-tsc: ^2.1.0 + peerDependenciesMeta: + vue-tsc: + optional: true + + prettier-plugin-tailwindcss@0.6.11: + resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + prettier@3.5.3: + resolution: {integrity: sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==} + engines: {node: '>=14'} + hasBin: true + + pretty-bytes@5.6.0: + resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} + engines: {node: '>=6'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + protobufjs@7.4.0: + resolution: {integrity: sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==} + engines: {node: '>=12.0.0'} + + proxy-from-env@1.0.0: + resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + ps-tree@1.2.0: + resolution: {integrity: sha512-0VnamPPYHl4uaU/nSFeZZpR21QAWRz+sRv4iW9+v/GS/J5U5iZB5BNN6J0RMoOvdx2gWM2+ZFMIm58q24e4UYA==} + engines: {node: '>= 0.10'} + hasBin: true + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qrcode.react@3.1.0: + resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + quansync@0.2.10: + resolution: {integrity: sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-hook-form@7.39.5: + resolution: {integrity: sha512-OE0HKyz5IPc6svN2wd+e+evidZrw4O4WZWAWYzQVZuHi+hYnHFSLnxOq0ddjbdmaLIsLHut/ab7j72y2QT3+KA==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.6: + resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} + engines: {node: '>= 0.4'} + + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regexp.prototype.flags@1.5.2: + resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + engines: {node: '>= 0.4'} + + request-progress@3.0.0: + resolution: {integrity: sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + + resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup@4.40.0: + resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sass@1.87.0: + resolution: {integrity: sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==} + engines: {node: '>=14.0.0'} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + sharp@0.34.1: + resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + shell-quote@1.8.2: + resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + slice-ansi@5.0.0: + resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} + engines: {node: '>=12'} + + slice-ansi@7.1.0: + resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + split@0.3.3: + resolution: {integrity: sha512-wD2AeVmxXRBoX44wAycgjVpMhvbwdI2aZjCkvfNcH1YqHQvJVa1duWc73OyVGJUc05fhFaTZeQ/PYsrmyH0JVA==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + sshpk@1.18.0: + resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} + engines: {node: '>=0.10.0'} + hasBin: true + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + start-server-and-test@2.0.11: + resolution: {integrity: sha512-TN39gLzPhHAflxyOkE/oMfQGj+pj3JgF6qVicFH/JrXt7xXktidKXwqfRga+ve7lVA8+RgPZVc25VrEPRScaDw==} + engines: {node: '>=16'} + hasBin: true + + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + + stop-iteration-iterator@1.0.0: + resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} + engines: {node: '>= 0.4'} + + stream-combiner@0.0.4: + resolution: {integrity: sha512-rT00SPnTVyRsaSz5zgSPma/aHSOic5U1prhYdRy5HS2kTZviFpmDgzilbtsJsxiroqACmayynDN/9VzIbX5DOw==} + + string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string.prototype.includes@2.0.0: + resolution: {integrity: sha512-E34CkBgyeqNDcrbU76cDjL5JLcVrtSdYq0MEh/B10r17pRP4ciHLwTgnuLV8Ay6cgEMLkcBkFCKyFZ43YldYzg==} + + string.prototype.matchall@4.0.11: + resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + + tailwindcss@3.4.14: + resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} + engines: {node: '>=14.0.0'} + hasBin: true + + tailwindcss@4.1.4: + resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thirty-two@1.0.2: + resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==} + engines: {node: '>=0.2.6'} + + throttleit@1.0.1: + resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinycolor2@1.4.2: + resolution: {integrity: sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + + tinypool@1.0.2: + resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@1.4.1: + resolution: {integrity: sha512-5RU2/lxTA3YUZxju61HO2U6EoZLvBLtmV2mbTvqyu4a/7s7RmJPT+1YekhMVsQhznRWk/czIwDUg+V8Q9ZuG4w==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + ts-error@1.0.6: + resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + ts-poet@6.11.0: + resolution: {integrity: sha512-r5AGF8vvb+GjBsnqiTqbLhN1/U2FJt6BI+k0dfCrkKzWvUhNlwMmq9nDHuucHs45LomgHjZPvYj96dD3JawjJA==} + + ts-proto-descriptors@2.0.0: + resolution: {integrity: sha512-wHcTH3xIv11jxgkX5OyCSFfw27agpInAd6yh89hKG6zqIXnjW9SYqSER2CVQxdPj4czeOhGagNvZBEbJPy7qkw==} + + ts-proto@2.7.0: + resolution: {integrity: sha512-BGHjse2wTOeswOqnnPKinpxmbaRd882so/e1En6ww59YMG7AO9Kg4vPpJcbVfrpBixPRDqHafXD/RDyd2T99GA==} + hasBin: true + + tsconfck@3.1.5: + resolution: {integrity: sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.4.0: + resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + turbo-darwin-64@2.5.0: + resolution: {integrity: sha512-fP1hhI9zY8hv0idym3hAaXdPi80TLovmGmgZFocVAykFtOxF+GlfIgM/l4iLAV9ObIO4SUXPVWHeBZQQ+Hpjag==} + cpu: [x64] + os: [darwin] + + turbo-darwin-arm64@2.5.0: + resolution: {integrity: sha512-p9sYq7kXH7qeJwIQE86cOWv/xNqvow846l6c/qWc26Ib1ci5W7V0sI5thsrP3eH+VA0d+SHalTKg5SQXgNQBWA==} + cpu: [arm64] + os: [darwin] + + turbo-linux-64@2.5.0: + resolution: {integrity: sha512-1iEln2GWiF3iPPPS1HQJT6ZCFXynJPd89gs9SkggH2EJsj3eRUSVMmMC8y6d7bBbhBFsiGGazwFIYrI12zs6uQ==} + cpu: [x64] + os: [linux] + + turbo-linux-arm64@2.5.0: + resolution: {integrity: sha512-bKBcbvuQHmsX116KcxHJuAcppiiBOfivOObh2O5aXNER6mce7YDDQJy00xQQNp1DhEfcSV2uOsvb3O3nN2cbcA==} + cpu: [arm64] + os: [linux] + + turbo-windows-64@2.5.0: + resolution: {integrity: sha512-9BCo8oQ7BO7J0K913Czbc3tw8QwLqn2nTe4E47k6aVYkM12ASTScweXPTuaPFP5iYXAT6z5Dsniw704Ixa5eGg==} + cpu: [x64] + os: [win32] + + turbo-windows-arm64@2.5.0: + resolution: {integrity: sha512-OUHCV+ueXa3UzfZ4co/ueIHgeq9B2K48pZwIxKSm5VaLVuv8M13MhM7unukW09g++dpdrrE1w4IOVgxKZ0/exg==} + cpu: [arm64] + os: [win32] + + turbo@2.5.0: + resolution: {integrity: sha512-PvSRruOsitjy6qdqwIIyolv99+fEn57gP6gn4zhsHTEcCYgXPhv6BAxzAjleS8XKpo+Y582vTTA9nuqYDmbRuA==} + hasBin: true + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-intl@3.26.5: + resolution: {integrity: sha512-OdsJnC/znPvHCHLQH/duvQNXnP1w0hPfS+tkSi3mAbfjYBGh4JnyfdwkQBfIVf7t8gs9eSX/CntxUMvtKdG2MQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + vite-node@3.1.2: + resolution: {integrity: sha512-/8iMryv46J3aK13iUXsei5G/A3CUlW4665THCPS+K8xAaqrVWiGB4RfXMQXCLjpK9P2eK//BczrVkn5JLAk6DA==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@6.3.2: + resolution: {integrity: sha512-ZSvGOXKGceizRQIZSz7TGJ0pS3QLlVY/9hwxVh17W3re67je1RKYzFHivZ/t0tubU78Vkyb9WnHPENSBCzbckg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.1.2: + resolution: {integrity: sha512-WaxpJe092ID1C0mr+LH9MmNrhfzi8I65EX/NRU/Ld016KqQNRgxSOlGNP1hHN+a/F8L15Mh8klwaF77zR3GeDQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.1.2 + '@vitest/ui': 3.1.2 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + wait-on@8.0.3: + resolution: {integrity: sha512-nQFqAFzZDeRxsu7S3C7LbuxslHhk+gnJZHyethuGKAn2IVleIbTB9I3vJSQiSR+DifUqmdzfPMoMPJfLqMF2vw==} + engines: {node: '>=12.0.0'} + hasBin: true + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + + which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + + which-builtin-type@1.1.4: + resolution: {integrity: sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.1: + resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.7.1: + resolution: {integrity: sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@adobe/css-tools@4.4.0': {} + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@asamuzakjp/css-color@3.1.4': + dependencies: + '@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-color-parser': 3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + lru-cache: 10.4.3 + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.26.8': {} + + '@babel/core@7.26.10': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.27.0 + '@babel/helper-compilation-targets': 7.27.0 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.10) + '@babel/helpers': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 + convert-source-map: 2.0.0 + debug: 4.4.0(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/eslint-parser@7.25.9(@babel/core@7.26.10)(eslint@8.57.1)': + dependencies: + '@babel/core': 7.26.10 + '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 + eslint: 8.57.1 + eslint-visitor-keys: 2.1.0 + semver: 6.3.1 + + '@babel/generator@7.27.0': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.0': + dependencies: + '@babel/compat-data': 7.26.8 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.25.9': + dependencies: + '@babel/traverse': 7.27.0 + '@babel/types': 7.27.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.27.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.26.5': {} + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.25.9': {} + + '@babel/helpers@7.27.0': + dependencies: + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 + + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@babel/plugin-transform-react-jsx-self@7.25.9(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-transform-react-jsx-source@7.25.9(@babel/core@7.26.10)': + dependencies: + '@babel/core': 7.26.10 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/runtime@7.27.0': + dependencies: + regenerator-runtime: 0.14.1 + + '@babel/template@7.27.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + + '@babel/traverse@7.27.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.27.0 + '@babel/parser': 7.27.0 + '@babel/template': 7.27.0 + '@babel/types': 7.27.0 + debug: 4.4.0(supports-color@5.5.0) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.27.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@bufbuild/buf-darwin-arm64@1.53.0': + optional: true + + '@bufbuild/buf-darwin-x64@1.53.0': + optional: true + + '@bufbuild/buf-linux-aarch64@1.53.0': + optional: true + + '@bufbuild/buf-linux-armv7@1.53.0': + optional: true + + '@bufbuild/buf-linux-x64@1.53.0': + optional: true + + '@bufbuild/buf-win32-arm64@1.53.0': + optional: true + + '@bufbuild/buf-win32-x64@1.53.0': + optional: true + + '@bufbuild/buf@1.53.0': + optionalDependencies: + '@bufbuild/buf-darwin-arm64': 1.53.0 + '@bufbuild/buf-darwin-x64': 1.53.0 + '@bufbuild/buf-linux-aarch64': 1.53.0 + '@bufbuild/buf-linux-armv7': 1.53.0 + '@bufbuild/buf-linux-x64': 1.53.0 + '@bufbuild/buf-win32-arm64': 1.53.0 + '@bufbuild/buf-win32-x64': 1.53.0 + + '@bufbuild/protobuf@2.2.2': {} + + '@bufbuild/protobuf@2.2.5': {} + + '@bufbuild/protocompile@0.0.1(@bufbuild/buf@1.53.0)': + dependencies: + '@bufbuild/buf': 1.53.0 + '@bufbuild/protobuf': 2.2.2 + fflate: 0.8.2 + + '@changesets/apply-release-plan@7.0.12': + dependencies: + '@changesets/config': 3.1.1 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.1 + + '@changesets/assemble-release-plan@6.0.6': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.1 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.29.2': + dependencies: + '@changesets/apply-release-plan': 7.0.12 + '@changesets/assemble-release-plan': 6.0.6 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.1 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.10 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + ci-info: 3.9.0 + enquirer: 2.4.1 + external-editor: 3.1.0 + fs-extra: 7.0.1 + mri: 1.2.0 + p-limit: 2.3.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.1 + spawndamnit: 3.0.1 + term-size: 2.2.1 + + '@changesets/config@3.1.1': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.3': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.1 + + '@changesets/get-release-plan@4.0.10': + dependencies: + '@changesets/assemble-release-plan': 6.0.6 + '@changesets/config': 3.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.5 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.1': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 3.14.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.5': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.1 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.1 + prettier: 2.8.8 + + '@colors/colors@1.5.0': + optional: true + + '@connectrpc/connect-node@2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2))': + dependencies: + '@bufbuild/protobuf': 2.2.2 + '@connectrpc/connect': 2.0.0(@bufbuild/protobuf@2.2.2) + + '@connectrpc/connect-web@2.0.0(@bufbuild/protobuf@2.2.2)(@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2))': + dependencies: + '@bufbuild/protobuf': 2.2.2 + '@connectrpc/connect': 2.0.0(@bufbuild/protobuf@2.2.2) + + '@connectrpc/connect@2.0.0(@bufbuild/protobuf@2.2.2)': + dependencies: + '@bufbuild/protobuf': 2.2.2 + + '@csstools/color-helpers@5.0.2': {} + + '@csstools/css-calc@2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-color-parser@3.0.9(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/color-helpers': 5.0.2 + '@csstools/css-calc': 2.1.3(@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3))(@csstools/css-tokenizer@3.0.3) + '@csstools/css-parser-algorithms': 3.0.4(@csstools/css-tokenizer@3.0.3) + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-parser-algorithms@3.0.4(@csstools/css-tokenizer@3.0.3)': + dependencies: + '@csstools/css-tokenizer': 3.0.3 + + '@csstools/css-tokenizer@3.0.3': {} + + '@cypress/request@3.0.8': + dependencies: + aws-sign2: 0.7.0 + aws4: 1.13.2 + caseless: 0.12.0 + combined-stream: 1.0.8 + extend: 3.0.2 + forever-agent: 0.6.1 + form-data: 4.0.2 + http-signature: 1.4.0 + is-typedarray: 1.0.0 + isstream: 0.1.2 + json-stringify-safe: 5.0.1 + mime-types: 2.1.35 + performance-now: 2.1.0 + qs: 6.14.0 + safe-buffer: 5.2.1 + tough-cookie: 5.1.2 + tunnel-agent: 0.6.0 + uuid: 8.3.2 + + '@cypress/xvfb@1.2.4(supports-color@8.1.1)': + dependencies: + debug: 3.2.7(supports-color@8.1.1) + lodash.once: 4.1.1 + transitivePeerDependencies: + - supports-color + + '@emnapi/runtime@1.4.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.2': + optional: true + + '@esbuild/android-arm64@0.25.2': + optional: true + + '@esbuild/android-arm@0.25.2': + optional: true + + '@esbuild/android-x64@0.25.2': + optional: true + + '@esbuild/darwin-arm64@0.25.2': + optional: true + + '@esbuild/darwin-x64@0.25.2': + optional: true + + '@esbuild/freebsd-arm64@0.25.2': + optional: true + + '@esbuild/freebsd-x64@0.25.2': + optional: true + + '@esbuild/linux-arm64@0.25.2': + optional: true + + '@esbuild/linux-arm@0.25.2': + optional: true + + '@esbuild/linux-ia32@0.25.2': + optional: true + + '@esbuild/linux-loong64@0.25.2': + optional: true + + '@esbuild/linux-mips64el@0.25.2': + optional: true + + '@esbuild/linux-ppc64@0.25.2': + optional: true + + '@esbuild/linux-riscv64@0.25.2': + optional: true + + '@esbuild/linux-s390x@0.25.2': + optional: true + + '@esbuild/linux-x64@0.25.2': + optional: true + + '@esbuild/netbsd-arm64@0.25.2': + optional: true + + '@esbuild/netbsd-x64@0.25.2': + optional: true + + '@esbuild/openbsd-arm64@0.25.2': + optional: true + + '@esbuild/openbsd-x64@0.25.2': + optional: true + + '@esbuild/sunos-x64@0.25.2': + optional: true + + '@esbuild/win32-arm64@0.25.2': + optional: true + + '@esbuild/win32-ia32@0.25.2': + optional: true + + '@esbuild/win32-x64@0.25.2': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.11.1': {} + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.0(supports-color@5.5.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@faker-js/faker@9.7.0': {} + + '@floating-ui/core@1.6.8': + dependencies: + '@floating-ui/utils': 0.2.8 + + '@floating-ui/dom@1.6.11': + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + + '@floating-ui/react-dom@2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/dom': 1.6.11 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@floating-ui/react@0.26.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@floating-ui/utils': 0.2.8 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + tabbable: 6.2.0 + + '@floating-ui/utils@0.2.8': {} + + '@formatjs/ecma402-abstract@2.2.4': + dependencies: + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/intl-localematcher': 0.5.8 + tslib: 2.8.1 + + '@formatjs/fast-memoize@2.2.3': + dependencies: + tslib: 2.8.1 + + '@formatjs/icu-messageformat-parser@2.9.4': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/icu-skeleton-parser': 1.8.8 + tslib: 2.8.1 + + '@formatjs/icu-skeleton-parser@1.8.8': + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + tslib: 2.8.1 + + '@formatjs/intl-localematcher@0.5.8': + dependencies: + tslib: 2.8.1 + + '@grpc/grpc-js@1.11.1': + dependencies: + '@grpc/proto-loader': 0.7.13 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.13': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.2.3 + protobufjs: 7.4.0 + yargs: 17.7.2 + + '@hapi/hoek@9.3.0': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@headlessui/react@2.1.9(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@floating-ui/react': 0.26.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@react-aria/focus': 3.18.3(react@19.1.0) + '@react-aria/interactions': 3.22.3(react@19.1.0) + '@tanstack/react-virtual': 3.10.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@heroicons/react@2.1.3(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.0(supports-color@5.5.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@img/sharp-darwin-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.1.0 + optional: true + + '@img/sharp-darwin-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.1.0 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-darwin-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-arm@1.1.0': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.1.0': + optional: true + + '@img/sharp-libvips-linux-s390x@1.1.0': + optional: true + + '@img/sharp-libvips-linux-x64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.1.0': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.1.0': + optional: true + + '@img/sharp-linux-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.1.0 + optional: true + + '@img/sharp-linux-arm@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.1.0 + optional: true + + '@img/sharp-linux-s390x@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.1.0 + optional: true + + '@img/sharp-linux-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.1': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + optional: true + + '@img/sharp-wasm32@0.34.1': + dependencies: + '@emnapi/runtime': 1.4.3 + optional: true + + '@img/sharp-win32-ia32@0.34.1': + optional: true + + '@img/sharp-win32-x64@0.34.1': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.27.0 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.27.0 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.0.4 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.7.1 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@next/env@15.4.0-canary.86': {} + + '@next/eslint-plugin-next@14.2.18': + dependencies: + glob: 10.3.10 + + '@next/swc-darwin-arm64@15.4.0-canary.86': + optional: true + + '@next/swc-darwin-x64@15.4.0-canary.86': + optional: true + + '@next/swc-linux-arm64-gnu@15.4.0-canary.86': + optional: true + + '@next/swc-linux-arm64-musl@15.4.0-canary.86': + optional: true + + '@next/swc-linux-x64-gnu@15.4.0-canary.86': + optional: true + + '@next/swc-linux-x64-musl@15.4.0-canary.86': + optional: true + + '@next/swc-win32-arm64-msvc@15.4.0-canary.86': + optional: true + + '@next/swc-win32-x64-msvc@15.4.0-canary.86': + optional: true + + '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': + dependencies: + eslint-scope: 5.1.1 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@otplib/core@12.0.1': {} + + '@otplib/plugin-crypto@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + + '@otplib/plugin-thirty-two@12.0.1': + dependencies: + '@otplib/core': 12.0.1 + thirty-two: 1.0.2 + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.52.0': + dependencies: + playwright: 1.52.0 + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + + '@react-aria/focus@3.18.3(react@19.1.0)': + dependencies: + '@react-aria/interactions': 3.22.3(react@19.1.0) + '@react-aria/utils': 3.25.3(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.5 + clsx: 2.1.1 + react: 19.1.0 + + '@react-aria/interactions@3.22.3(react@19.1.0)': + dependencies: + '@react-aria/ssr': 3.9.6(react@19.1.0) + '@react-aria/utils': 3.25.3(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.5 + react: 19.1.0 + + '@react-aria/ssr@3.9.6(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.1.0 + + '@react-aria/utils@3.25.3(react@19.1.0)': + dependencies: + '@react-aria/ssr': 3.9.6(react@19.1.0) + '@react-stately/utils': 3.10.4(react@19.1.0) + '@react-types/shared': 3.25.0(react@19.1.0) + '@swc/helpers': 0.5.15 + clsx: 2.1.1 + react: 19.1.0 + + '@react-stately/utils@3.10.4(react@19.1.0)': + dependencies: + '@swc/helpers': 0.5.15 + react: 19.1.0 + + '@react-types/shared@3.25.0(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@rollup/rollup-android-arm-eabi@4.40.0': + optional: true + + '@rollup/rollup-android-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-x64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.40.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.40.0': + optional: true + + '@rushstack/eslint-patch@1.10.4': {} + + '@sideway/address@4.1.5': + dependencies: + '@hapi/hoek': 9.3.0 + + '@sideway/formula@3.0.1': {} + + '@sideway/pinpoint@2.0.0': {} + + '@swc/counter@0.1.3': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@swc/helpers@0.5.5': + dependencies: + '@swc/counter': 0.1.3 + tslib: 2.8.1 + + '@tailwindcss/forms@0.5.3(tailwindcss@4.1.4)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 4.1.4 + + '@tailwindcss/forms@0.5.7(tailwindcss@3.4.14)': + dependencies: + mini-svg-data-uri: 1.4.4 + tailwindcss: 3.4.14 + + '@tanstack/react-virtual@3.10.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@tanstack/virtual-core': 3.10.6 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + '@tanstack/virtual-core@3.10.6': {} + + '@testing-library/dom@10.4.0': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.27.0 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.0 + aria-query: 5.3.0 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/react@16.3.0(@testing-library/dom@10.4.0)(@types/react-dom@19.1.2(@types/react@19.1.2))(@types/react@19.1.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.0 + '@testing-library/dom': 10.4.0 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.2 + '@types/react-dom': 19.1.2(@types/react@19.1.2) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.7 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.27.0 + '@babel/types': 7.27.0 + + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.27.0 + + '@types/estree@1.0.7': {} + + '@types/json5@0.0.29': {} + + '@types/ms@2.1.0': {} + + '@types/node@12.20.55': {} + + '@types/node@22.14.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.1.2(@types/react@19.1.2)': + dependencies: + '@types/react': 19.1.2 + + '@types/react@19.1.2': + dependencies: + csstype: 3.1.3 + + '@types/sinonjs__fake-timers@8.1.1': {} + + '@types/sizzle@2.3.9': {} + + '@types/tinycolor2@1.4.3': {} + + '@types/uuid@10.0.0': {} + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.14.1 + optional: true + + '@typescript-eslint/eslint-plugin@8.15.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.15.0 + '@typescript-eslint/type-utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.15.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + + '@typescript-eslint/scope-manager@8.15.0': + dependencies: + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/visitor-keys': 8.15.0 + + '@typescript-eslint/type-utils@8.15.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.15.0(eslint@8.57.1)(typescript@5.8.3) + debug: 4.4.0(supports-color@5.5.0) + eslint: 8.57.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@7.18.0': {} + + '@typescript-eslint/types@8.15.0': {} + + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.4.0(supports-color@5.5.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/typescript-estree@8.15.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/visitor-keys': 8.15.0 + debug: 4.4.0(supports-color@5.5.0) + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 1.4.1(typescript@5.8.3) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.15.0(eslint@8.57.1)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.15.0 + '@typescript-eslint/types': 8.15.0 + '@typescript-eslint/typescript-estree': 8.15.0(typescript@5.8.3) + eslint: 8.57.1 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@7.18.0': + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + + '@typescript-eslint/visitor-keys@8.15.0': + dependencies: + '@typescript-eslint/types': 8.15.0 + eslint-visitor-keys: 4.2.0 + + '@ungap/structured-clone@1.2.0': {} + + '@vercel/analytics@1.3.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0)': + dependencies: + server-only: 0.0.1 + optionalDependencies: + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + + '@vercel/git-hooks@1.0.0': {} + + '@vitejs/plugin-react@4.4.1(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1))': + dependencies: + '@babel/core': 7.26.10 + '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.10) + '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.10) + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.1.2': + dependencies: + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.2.0 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.1.2(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1))': + dependencies: + '@vitest/spy': 3.1.2 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + + '@vitest/pretty-format@3.1.2': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.1.2': + dependencies: + '@vitest/utils': 3.1.2 + pathe: 2.0.3 + + '@vitest/snapshot@3.1.2': + dependencies: + '@vitest/pretty-format': 3.1.2 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.1.2': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@3.1.2': + dependencies: + '@vitest/pretty-format': 3.1.2 + loupe: 3.1.3 + tinyrainbow: 2.0.0 + + abbrev@1.1.1: {} + + abort-controller-x@0.4.3: {} + + acorn-jsx@5.3.2(acorn@8.12.1): + dependencies: + acorn: 8.12.1 + + acorn@8.12.1: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.3: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + aproba@2.0.0: {} + + arch@2.2.0: {} + + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + arg@5.0.2: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-query@5.1.3: + dependencies: + deep-equal: 2.2.3 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + array-buffer-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + + array-includes@3.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + is-string: 1.0.7 + + array-union@2.1.0: {} + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + + array.prototype.findlastindex@1.2.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + + array.prototype.flat@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + + array.prototype.flatmap@1.3.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-shim-unscopables: 1.0.2 + + arraybuffer.prototype.slice@1.0.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-plus@1.0.0: {} + + assertion-error@2.0.1: {} + + ast-types-flow@0.0.8: {} + + astral-regex@2.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + autoprefixer@10.4.21(postcss@8.5.3): + dependencies: + browserslist: 4.24.4 + caniuse-lite: 1.0.30001715 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.0.0 + + aws-sign2@0.7.0: {} + + aws4@1.13.2: {} + + axe-core@4.10.0: {} + + axios@1.8.4(debug@4.4.0): + dependencies: + follow-redirects: 1.15.9(debug@4.4.0) + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + axobject-query@3.1.1: + dependencies: + deep-equal: 2.2.3 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + binary-extensions@2.3.0: {} + + blob-util@2.0.2: {} + + bluebird@3.7.2: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001715 + electron-to-chromium: 1.5.140 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.4) + + buffer-crc32@0.2.13: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-require@5.1.0(esbuild@0.25.2): + dependencies: + esbuild: 0.25.2 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + cachedir@2.4.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.7: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001715: {} + + case-anything@2.1.13: {} + + caseless@0.12.0: {} + + chai@5.2.0: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.3 + pathval: 2.0.0 + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.4.1: {} + + chardet@0.7.0: {} + + check-error@2.1.1: {} + + check-more-types@2.24.0: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chownr@2.0.0: {} + + ci-info@3.9.0: {} + + ci-info@4.2.0: {} + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + + cli-truncate@4.0.0: + dependencies: + slice-ansi: 5.0.0 + string-width: 7.2.0 + + client-only@0.0.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@1.2.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + optional: true + + color-support@1.1.3: {} + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + colorette@2.0.20: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.1.0: {} + + commander@4.1.1: {} + + commander@6.2.1: {} + + common-tags@1.8.2: {} + + concat-map@0.0.1: {} + + concurrently@9.1.2: + dependencies: + chalk: 4.1.2 + lodash: 4.17.21 + rxjs: 7.8.2 + shell-quote: 1.8.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + + consola@3.4.2: {} + + console-control-strings@1.1.0: {} + + convert-source-map@2.0.0: {} + + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + + core-util-is@1.0.2: {} + + cross-spawn@7.0.3: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssesc@3.0.0: {} + + cssstyle@4.3.1: + dependencies: + '@asamuzakjp/css-color': 3.1.4 + rrweb-cssom: 0.8.0 + + csstype@3.1.3: {} + + cypress@14.3.2: + dependencies: + '@cypress/request': 3.0.8 + '@cypress/xvfb': 1.2.4(supports-color@8.1.1) + '@types/sinonjs__fake-timers': 8.1.1 + '@types/sizzle': 2.3.9 + arch: 2.2.0 + blob-util: 2.0.2 + bluebird: 3.7.2 + buffer: 5.7.1 + cachedir: 2.4.0 + chalk: 4.1.2 + check-more-types: 2.24.0 + ci-info: 4.2.0 + cli-cursor: 3.1.0 + cli-table3: 0.6.5 + commander: 6.2.1 + common-tags: 1.8.2 + dayjs: 1.11.13 + debug: 4.4.0(supports-color@8.1.1) + enquirer: 2.4.1 + eventemitter2: 6.4.7 + execa: 4.1.0 + executable: 4.1.1 + extract-zip: 2.0.1(supports-color@8.1.1) + figures: 3.2.0 + fs-extra: 9.1.0 + getos: 3.2.1 + is-installed-globally: 0.4.0 + lazy-ass: 1.6.0 + listr2: 3.14.0(enquirer@2.4.1) + lodash: 4.17.21 + log-symbols: 4.1.0 + minimist: 1.2.8 + ospath: 1.2.2 + pretty-bytes: 5.6.0 + process: 0.11.10 + proxy-from-env: 1.0.0 + request-progress: 3.0.0 + semver: 7.7.1 + supports-color: 8.1.1 + tmp: 0.2.3 + tree-kill: 1.2.2 + untildify: 4.0.0 + yauzl: 2.10.0 + + damerau-levenshtein@1.0.8: {} + + dashdash@1.14.1: + dependencies: + assert-plus: 1.0.0 + + data-uri-to-buffer@4.0.1: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-view-buffer@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + data-view-byte-offset@1.0.0: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + + dayjs@1.11.13: {} + + debug@3.2.7(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + debug@4.3.7: + dependencies: + ms: 2.1.3 + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + debug@4.4.0(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + debug@4.4.0(supports-color@8.1.1): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 8.1.1 + + decimal.js@10.5.0: {} + + deep-eql@5.0.2: {} + + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.1.1 + is-array-buffer: 3.0.4 + is-date-object: 1.0.5 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + side-channel: 1.1.0 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.2 + which-typed-array: 1.1.15 + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + dequal@2.0.3: {} + + detect-indent@6.1.0: {} + + detect-libc@1.0.3: {} + + detect-libc@2.0.4: {} + + didyoumean@1.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dlv@1.1.3: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dotenv-cli@8.0.0: + dependencies: + cross-spawn: 7.0.6 + dotenv: 16.5.0 + dotenv-expand: 10.0.0 + minimist: 1.2.8 + + dotenv-expand@10.0.0: {} + + dotenv@16.0.3: {} + + dotenv@16.5.0: {} + + dprint-node@1.0.8: + dependencies: + detect-libc: 1.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer@0.1.2: {} + + eastasianwidth@0.2.0: {} + + ecc-jsbn@0.1.2: + dependencies: + jsbn: 0.1.1 + safer-buffer: 2.1.2 + + electron-to-chromium@1.5.140: {} + + emoji-regex@10.4.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.17.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@6.0.0: {} + + env-cmd@10.1.0: + dependencies: + commander: 4.1.1 + cross-spawn: 7.0.6 + + environment@1.1.0: {} + + es-abstract@1.23.3: + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.2 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.2 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + + es-define-property@1.0.0: + dependencies: + get-intrinsic: 1.3.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.1.1 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.0.0 + + es-iterator-helpers@1.0.19: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-set-tostringtag: 2.0.3 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + iterator.prototype: 1.1.2 + safe-array-concat: 1.1.2 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.0.3: + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.0.2: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.2.1: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + + esbuild@0.25.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.2 + '@esbuild/android-arm': 0.25.2 + '@esbuild/android-arm64': 0.25.2 + '@esbuild/android-x64': 0.25.2 + '@esbuild/darwin-arm64': 0.25.2 + '@esbuild/darwin-x64': 0.25.2 + '@esbuild/freebsd-arm64': 0.25.2 + '@esbuild/freebsd-x64': 0.25.2 + '@esbuild/linux-arm': 0.25.2 + '@esbuild/linux-arm64': 0.25.2 + '@esbuild/linux-ia32': 0.25.2 + '@esbuild/linux-loong64': 0.25.2 + '@esbuild/linux-mips64el': 0.25.2 + '@esbuild/linux-ppc64': 0.25.2 + '@esbuild/linux-riscv64': 0.25.2 + '@esbuild/linux-s390x': 0.25.2 + '@esbuild/linux-x64': 0.25.2 + '@esbuild/netbsd-arm64': 0.25.2 + '@esbuild/netbsd-x64': 0.25.2 + '@esbuild/openbsd-arm64': 0.25.2 + '@esbuild/openbsd-x64': 0.25.2 + '@esbuild/sunos-x64': 0.25.2 + '@esbuild/win32-arm64': 0.25.2 + '@esbuild/win32-ia32': 0.25.2 + '@esbuild/win32-x64': 0.25.2 + + escalade@3.2.0: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@14.2.18(eslint@8.57.1)(typescript@5.8.3): + dependencies: + '@next/eslint-plugin-next': 14.2.18 + '@rushstack/eslint-patch': 1.10.4 + '@typescript-eslint/eslint-plugin': 8.15.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint@8.57.1)(typescript@5.8.3) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + eslint-plugin-jsx-a11y: 6.9.0(eslint@8.57.1) + eslint-plugin-react: 7.35.0(eslint@8.57.1) + eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-config-prettier@9.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-config-turbo@2.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + eslint-plugin-turbo: 2.1.0(eslint@8.57.1) + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7(supports-color@8.1.1) + is-core-module: 2.15.1 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.0(supports-color@5.5.0) + enhanced-resolve: 5.17.1 + eslint: 8.57.1 + eslint-module-utils: 2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1) + fast-glob: 3.3.2 + get-tsconfig: 4.8.0 + is-bun-module: 1.1.0 + is-glob: 4.0.3 + optionalDependencies: + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1) + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + + eslint-module-utils@2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1): + dependencies: + debug: 3.2.7(supports-color@8.1.1) + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7(supports-color@8.1.1) + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.2(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.57.1))(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.8.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.9.0(eslint@8.57.1): + dependencies: + aria-query: 5.1.3 + array-includes: 3.1.8 + array.prototype.flatmap: 1.3.2 + ast-types-flow: 0.0.8 + axe-core: 4.10.0 + axobject-query: 3.1.1 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + es-iterator-helpers: 1.0.19 + eslint: 8.57.1 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + safe-regex-test: 1.0.3 + string.prototype.includes: 2.0.0 + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react@7.35.0(eslint@8.57.1): + dependencies: + array-includes: 3.1.8 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.2 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.0.19 + eslint: 8.57.1 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.8 + object.fromentries: 2.0.8 + object.values: 1.2.0 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.11 + string.prototype.repeat: 1.0.0 + + eslint-plugin-turbo@2.1.0(eslint@8.57.1): + dependencies: + dotenv: 16.0.3 + eslint: 8.57.1 + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@2.1.0: {} + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.0: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.7 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.7 + + esutils@2.0.3: {} + + event-stream@3.3.4: + dependencies: + duplexer: 0.1.2 + from: 0.1.7 + map-stream: 0.1.0 + pause-stream: 0.0.11 + split: 0.3.3 + stream-combiner: 0.0.4 + through: 2.3.8 + + eventemitter2@6.4.7: {} + + eventemitter3@5.0.1: {} + + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@8.0.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + + executable@4.1.1: + dependencies: + pify: 2.3.0 + + expect-type@1.2.1: {} + + extend@3.0.2: {} + + extendable-error@0.1.7: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + extract-zip@2.0.1(supports-color@8.1.1): + dependencies: + debug: 4.4.0(supports-color@8.1.1) + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fflate@0.8.2: {} + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.1: {} + + follow-redirects@1.15.9(debug@4.4.0): + optionalDependencies: + debug: 4.4.0 + + for-each@0.3.3: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forever-agent@0.6.1: {} + + form-data@4.0.2: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + fraction.js@4.3.7: {} + + from@0.1.7: {} + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + + functions-have-names@1.2.3: {} + + gauge@3.0.2: + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + gaxios@7.1.0: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.3.0: {} + + get-intrinsic@1.2.4: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@5.2.0: + dependencies: + pump: 3.0.2 + + get-stream@6.0.1: {} + + get-stream@8.0.1: {} + + get-symbol-description@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + + get-tsconfig@4.8.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + getos@3.2.1: + dependencies: + async: 3.2.6 + + getpass@0.1.7: + dependencies: + assert-plus: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.3.10: + dependencies: + foreground-child: 3.3.0 + jackspeak: 2.3.6 + minimatch: 9.0.5 + minipass: 7.1.2 + path-scurry: 1.11.1 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-dirs@3.0.1: + dependencies: + ini: 2.0.0 + + globals@11.12.0: {} + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.0.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globrex@0.1.2: {} + + gopd@1.0.1: + dependencies: + get-intrinsic: 1.2.4 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + grpc-tools@1.13.0: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + transitivePeerDependencies: + - encoding + - supports-color + + has-bigints@1.0.2: {} + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.0 + + has-proto@1.0.3: {} + + has-symbols@1.0.3: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + http-signature@1.4.0: + dependencies: + assert-plus: 1.0.0 + jsprim: 2.0.2 + sshpk: 1.18.0 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + human-id@4.1.1: {} + + human-signals@1.1.1: {} + + human-signals@2.1.0: {} + + human-signals@5.0.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + immutable@5.1.1: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@2.0.0: {} + + internal-slot@1.0.7: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + + intl-messageformat@10.7.7: + dependencies: + '@formatjs/ecma402-abstract': 2.2.4 + '@formatjs/fast-memoize': 2.2.3 + '@formatjs/icu-messageformat-parser': 2.9.4 + tslib: 2.8.1 + + is-arguments@1.1.1: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.4: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + + is-arrayish@0.3.2: + optional: true + + is-async-function@2.0.0: + dependencies: + has-tostringtag: 1.0.2 + + is-bigint@1.0.4: + dependencies: + has-bigints: 1.0.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.1.2: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-bun-module@1.1.0: + dependencies: + semver: 7.7.1 + + is-callable@1.2.7: {} + + is-core-module@2.15.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.1: + dependencies: + is-typed-array: 1.1.13 + + is-date-object@1.0.5: + dependencies: + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.0.2: + dependencies: + call-bind: 1.0.7 + + is-fullwidth-code-point@3.0.0: {} + + is-fullwidth-code-point@4.0.0: {} + + is-fullwidth-code-point@5.0.0: + dependencies: + get-east-asian-width: 1.3.0 + + is-generator-function@1.0.10: + dependencies: + has-tostringtag: 1.0.2 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-installed-globally@0.4.0: + dependencies: + global-dirs: 3.0.1 + is-path-inside: 3.0.3 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.1.4: + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.3: + dependencies: + call-bind: 1.0.7 + + is-stream@2.0.1: {} + + is-stream@3.0.0: {} + + is-string@1.0.7: + dependencies: + has-tostringtag: 1.0.2 + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-symbol@1.0.4: + dependencies: + has-symbols: 1.0.3 + + is-typed-array@1.1.13: + dependencies: + which-typed-array: 1.1.15 + + is-typedarray@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.0.2: + dependencies: + call-bind: 1.0.7 + + is-weakset@2.0.3: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.3.0 + + is-windows@1.0.2: {} + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + isstream@0.1.2: {} + + iterator.prototype@1.1.2: + dependencies: + define-properties: 1.2.1 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + reflect.getprototypeof: 1.0.6 + set-function-name: 2.0.2 + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jiti@1.21.6: {} + + joi@17.13.3: + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + + jose@5.8.0: {} + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsbn@0.1.1: {} + + jsdom@26.1.0: + dependencies: + cssstyle: 4.3.1 + data-urls: 5.0.0 + decimal.js: 10.5.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.20 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.18.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsprim@2.0.2: + dependencies: + assert-plus: 1.0.0 + extsprintf: 1.3.0 + json-schema: 0.4.0 + verror: 1.10.0 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.8 + array.prototype.flat: 1.3.2 + object.assign: 4.1.5 + object.values: 1.2.0 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + lazy-ass@1.6.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@2.1.0: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lint-staged@15.5.1: + dependencies: + chalk: 5.4.1 + commander: 13.1.0 + debug: 4.4.0 + execa: 8.0.1 + lilconfig: 3.1.3 + listr2: 8.3.2 + micromatch: 4.0.8 + pidtree: 0.6.0 + string-argv: 0.3.2 + yaml: 2.7.1 + transitivePeerDependencies: + - supports-color + + listr2@3.14.0(enquirer@2.4.1): + dependencies: + cli-truncate: 2.1.0 + colorette: 2.0.20 + log-update: 4.0.0 + p-map: 4.0.0 + rfdc: 1.4.1 + rxjs: 7.8.2 + through: 2.3.8 + wrap-ansi: 7.0.0 + optionalDependencies: + enquirer: 2.4.1 + + listr2@8.3.2: + dependencies: + cli-truncate: 4.0.0 + colorette: 2.0.20 + eventemitter3: 5.0.1 + log-update: 6.1.0 + rfdc: 1.4.1 + wrap-ansi: 9.0.0 + + load-tsconfig@0.2.5: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash.sortby@4.7.0: {} + + lodash.startcase@4.4.0: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + log-update@4.0.0: + dependencies: + ansi-escapes: 4.3.2 + cli-cursor: 3.1.0 + slice-ansi: 4.0.0 + wrap-ansi: 6.2.0 + + log-update@6.1.0: + dependencies: + ansi-escapes: 7.0.0 + cli-cursor: 5.0.0 + slice-ansi: 7.1.0 + strip-ansi: 7.1.0 + wrap-ansi: 9.0.0 + + long@5.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.1.3: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.469.0(react@19.1.0): + dependencies: + react: 19.1.0 + + lz-string@1.5.0: {} + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + make-dir-cli@4.0.0: + dependencies: + make-dir: 5.0.0 + meow: 13.2.0 + + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + + make-dir@5.0.0: {} + + map-stream@0.1.0: {} + + math-intrinsics@1.1.0: {} + + meow@13.2.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mimic-fn@2.1.0: {} + + mimic-fn@4.0.0: {} + + mimic-function@5.0.1: {} + + min-indent@1.0.1: {} + + mini-svg-data-uri@1.4.4: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.2: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + moment@2.30.1: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + next-intl@3.26.5(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react@19.1.0): + dependencies: + '@formatjs/intl-localematcher': 0.5.8 + negotiator: 1.0.0 + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + use-intl: 3.26.5(react@19.1.0) + + next-themes@0.2.1(next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + next: 15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + next@15.4.0-canary.86(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(sass@1.87.0): + dependencies: + '@next/env': 15.4.0-canary.86 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001715 + postcss: 8.4.31 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.4.0-canary.86 + '@next/swc-darwin-x64': 15.4.0-canary.86 + '@next/swc-linux-arm64-gnu': 15.4.0-canary.86 + '@next/swc-linux-arm64-musl': 15.4.0-canary.86 + '@next/swc-linux-x64-gnu': 15.4.0-canary.86 + '@next/swc-linux-x64-musl': 15.4.0-canary.86 + '@next/swc-win32-arm64-msvc': 15.4.0-canary.86 + '@next/swc-win32-x64-msvc': 15.4.0-canary.86 + '@playwright/test': 1.52.0 + sass: 1.87.0 + sharp: 0.34.1 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + nice-grpc-common@2.0.2: + dependencies: + ts-error: 1.0.6 + + nice-grpc@2.0.1: + dependencies: + '@grpc/grpc-js': 1.11.1 + abort-controller-x: 0.4.3 + nice-grpc-common: 2.0.2 + + node-addon-api@7.1.1: + optional: true + + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.19: {} + + nodemon@3.1.9: + dependencies: + chokidar: 3.6.0 + debug: 4.4.0(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.1 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + normalize-range@0.1.2: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@5.3.0: + dependencies: + path-key: 4.0.0 + + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + + nwsapi@2.2.20: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.2: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.5: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + + object.entries@1.1.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + + object.values@1.2.0: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@6.0.0: + dependencies: + mimic-fn: 4.0.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + os-tmpdir@1.0.2: {} + + ospath@1.2.2: {} + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@2.1.0: {} + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.10 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + pathval@2.0.0: {} + + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + + pend@1.2.0: {} + + performance-now@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: {} + + pidtree@0.6.0: {} + + pify@2.3.0: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + playwright-core@1.52.0: {} + + playwright@1.52.0: + dependencies: + playwright-core: 1.52.0 + optionalDependencies: + fsevents: 2.3.2 + + possible-typed-array-names@1.0.0: {} + + postcss-import@15.1.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + postcss-js@4.0.1(postcss@8.5.3): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.3 + + postcss-load-config@4.0.2(postcss@8.5.3): + dependencies: + lilconfig: 3.1.3 + yaml: 2.7.1 + optionalDependencies: + postcss: 8.5.3 + + postcss-load-config@6.0.1(jiti@1.21.6)(postcss@8.5.3)(yaml@2.7.1): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.6 + postcss: 8.5.3 + yaml: 2.7.1 + + postcss-nested@6.2.0(postcss@8.5.3): + dependencies: + postcss: 8.5.3 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.3: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3): + dependencies: + prettier: 3.5.3 + typescript: 5.8.3 + + prettier-plugin-tailwindcss@0.6.11(prettier-plugin-organize-imports@4.1.0(prettier@3.5.3)(typescript@5.8.3))(prettier@3.5.3): + dependencies: + prettier: 3.5.3 + optionalDependencies: + prettier-plugin-organize-imports: 4.1.0(prettier@3.5.3)(typescript@5.8.3) + + prettier@2.8.8: {} + + prettier@3.5.3: {} + + pretty-bytes@5.6.0: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process@0.11.10: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + protobufjs@7.4.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 22.14.1 + long: 5.2.3 + + proxy-from-env@1.0.0: {} + + proxy-from-env@1.1.0: {} + + ps-tree@1.2.0: + dependencies: + event-stream: 3.3.4 + + pstree.remy@1.1.8: {} + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + punycode@2.3.1: {} + + qrcode.react@3.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + quansync@0.2.10: {} + + queue-microtask@1.2.3: {} + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-hook-form@7.39.5(react@19.1.0): + dependencies: + react: 19.1.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react@19.1.0: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.1 + pify: 4.0.1 + strip-bom: 3.0.0 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@4.1.2: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.6: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + which-builtin-type: 1.1.4 + + regenerator-runtime@0.14.1: {} + + regexp.prototype.flags@1.5.2: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + + request-progress@3.0.0: + dependencies: + throttleit: 1.0.1 + + require-directory@2.1.1: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.5: + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.0.4: {} + + rfdc@1.4.1: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup@4.40.0: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.40.0 + '@rollup/rollup-android-arm64': 4.40.0 + '@rollup/rollup-darwin-arm64': 4.40.0 + '@rollup/rollup-darwin-x64': 4.40.0 + '@rollup/rollup-freebsd-arm64': 4.40.0 + '@rollup/rollup-freebsd-x64': 4.40.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.40.0 + '@rollup/rollup-linux-arm-musleabihf': 4.40.0 + '@rollup/rollup-linux-arm64-gnu': 4.40.0 + '@rollup/rollup-linux-arm64-musl': 4.40.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.40.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-musl': 4.40.0 + '@rollup/rollup-linux-s390x-gnu': 4.40.0 + '@rollup/rollup-linux-x64-gnu': 4.40.0 + '@rollup/rollup-linux-x64-musl': 4.40.0 + '@rollup/rollup-win32-arm64-msvc': 4.40.0 + '@rollup/rollup-win32-ia32-msvc': 4.40.0 + '@rollup/rollup-win32-x64-msvc': 4.40.0 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-array-concat@1.1.2: + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + + safe-buffer@5.2.1: {} + + safe-regex-test@1.0.3: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + + safer-buffer@2.1.2: {} + + sass@1.87.0: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.1 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.1: {} + + server-only@0.0.1: {} + + set-blocking@2.0.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + sharp@0.34.1: + dependencies: + color: 4.2.3 + detect-libc: 2.0.4 + semver: 7.7.1 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.1 + '@img/sharp-darwin-x64': 0.34.1 + '@img/sharp-libvips-darwin-arm64': 1.1.0 + '@img/sharp-libvips-darwin-x64': 1.1.0 + '@img/sharp-libvips-linux-arm': 1.1.0 + '@img/sharp-libvips-linux-arm64': 1.1.0 + '@img/sharp-libvips-linux-ppc64': 1.1.0 + '@img/sharp-libvips-linux-s390x': 1.1.0 + '@img/sharp-libvips-linux-x64': 1.1.0 + '@img/sharp-libvips-linuxmusl-arm64': 1.1.0 + '@img/sharp-libvips-linuxmusl-x64': 1.1.0 + '@img/sharp-linux-arm': 0.34.1 + '@img/sharp-linux-arm64': 0.34.1 + '@img/sharp-linux-s390x': 0.34.1 + '@img/sharp-linux-x64': 0.34.1 + '@img/sharp-linuxmusl-arm64': 0.34.1 + '@img/sharp-linuxmusl-x64': 0.34.1 + '@img/sharp-wasm32': 0.34.1 + '@img/sharp-win32-ia32': 0.34.1 + '@img/sharp-win32-x64': 0.34.1 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + shell-quote@1.8.2: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.0.6: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.2 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + optional: true + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.1 + + slash@3.0.0: {} + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + slice-ansi@5.0.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 4.0.0 + + slice-ansi@7.1.0: + dependencies: + ansi-styles: 6.2.1 + is-fullwidth-code-point: 5.0.0 + + source-map-js@1.2.1: {} + + source-map@0.8.0-beta.0: + dependencies: + whatwg-url: 7.1.0 + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + split@0.3.3: + dependencies: + through: 2.3.8 + + sprintf-js@1.0.3: {} + + sshpk@1.18.0: + dependencies: + asn1: 0.2.6 + assert-plus: 1.0.0 + bcrypt-pbkdf: 1.0.2 + dashdash: 1.14.1 + ecc-jsbn: 0.1.2 + getpass: 0.1.7 + jsbn: 0.1.1 + safer-buffer: 2.1.2 + tweetnacl: 0.14.5 + + stackback@0.0.2: {} + + start-server-and-test@2.0.11: + dependencies: + arg: 5.0.2 + bluebird: 3.7.2 + check-more-types: 2.24.0 + debug: 4.4.0 + execa: 5.1.1 + lazy-ass: 1.6.0 + ps-tree: 1.2.0 + wait-on: 8.0.3(debug@4.4.0) + transitivePeerDependencies: + - supports-color + + std-env@3.9.0: {} + + stop-iteration-iterator@1.0.0: + dependencies: + internal-slot: 1.0.7 + + stream-combiner@0.0.4: + dependencies: + duplexer: 0.1.2 + + string-argv@0.3.2: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + + string.prototype.includes@2.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + + string.prototype.matchall@4.0.11: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + regexp.prototype.flags: 1.5.2 + set-function-name: 2.0.2 + side-channel: 1.0.6 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + + string.prototype.trim@1.2.9: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + + string.prototype.trimend@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + commander: 4.1.1 + glob: 10.4.5 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + ts-interface-checker: 0.1.13 + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tabbable@6.2.0: {} + + tailwindcss@3.4.14: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.6 + lilconfig: 2.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-import: 15.1.0(postcss@8.5.3) + postcss-js: 4.0.1(postcss@8.5.3) + postcss-load-config: 4.0.2(postcss@8.5.3) + postcss-nested: 6.2.0(postcss@8.5.3) + postcss-selector-parser: 6.1.2 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + tailwindcss@4.1.4: {} + + tapable@2.2.1: {} + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + term-size@2.2.1: {} + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thirty-two@1.0.2: {} + + throttleit@1.0.1: {} + + through@2.3.8: {} + + tinybench@2.9.0: {} + + tinycolor2@1.4.2: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + tinypool@1.0.2: {} + + tinyrainbow@2.0.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmp@0.2.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toggle-selection@1.0.6: {} + + touch@3.1.1: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@0.0.3: {} + + tr46@1.0.1: + dependencies: + punycode: 2.3.1 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-api-utils@1.4.1(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + ts-error@1.0.6: {} + + ts-interface-checker@0.1.13: {} + + ts-poet@6.11.0: + dependencies: + dprint-node: 1.0.8 + + ts-proto-descriptors@2.0.0: + dependencies: + '@bufbuild/protobuf': 2.2.5 + + ts-proto@2.7.0: + dependencies: + '@bufbuild/protobuf': 2.2.5 + case-anything: 2.1.13 + ts-poet: 6.11.0 + ts-proto-descriptors: 2.0.0 + + tsconfck@3.1.5(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsup@8.4.0(jiti@1.21.6)(postcss@8.5.3)(typescript@5.8.3)(yaml@2.7.1): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.2) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.0(supports-color@5.5.0) + esbuild: 0.25.2 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@1.21.6)(postcss@8.5.3)(yaml@2.7.1) + resolve-from: 5.0.0 + rollup: 4.40.0 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.3 + typescript: 5.8.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + turbo-darwin-64@2.5.0: + optional: true + + turbo-darwin-arm64@2.5.0: + optional: true + + turbo-linux-64@2.5.0: + optional: true + + turbo-linux-arm64@2.5.0: + optional: true + + turbo-windows-64@2.5.0: + optional: true + + turbo-windows-arm64@2.5.0: + optional: true + + turbo@2.5.0: + optionalDependencies: + turbo-darwin-64: 2.5.0 + turbo-darwin-arm64: 2.5.0 + turbo-linux-64: 2.5.0 + turbo-linux-arm64: 2.5.0 + turbo-windows-64: 2.5.0 + turbo-windows-arm64: 2.5.0 + + tweetnacl@0.14.5: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + typed-array-buffer@1.0.2: + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + + typed-array-byte-length@1.0.1: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-byte-offset@1.0.2: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + + typed-array-length@1.0.6: + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + + typescript@5.8.3: {} + + unbox-primitive@1.0.2: + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + + undefsafe@2.0.5: {} + + undici-types@6.21.0: {} + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + untildify@4.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-intl@3.26.5(react@19.1.0): + dependencies: + '@formatjs/fast-memoize': 2.2.3 + intl-messageformat: 10.7.7 + react: 19.1.0 + + util-deprecate@1.0.2: {} + + uuid@11.1.0: {} + + uuid@8.3.2: {} + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.3.0 + + vite-node@3.1.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1): + dependencies: + cac: 6.7.14 + debug: 4.4.0(supports-color@5.5.0) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)): + dependencies: + debug: 4.4.0(supports-color@5.5.0) + globrex: 0.1.2 + tsconfck: 3.1.5(typescript@5.8.3) + optionalDependencies: + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + transitivePeerDependencies: + - supports-color + - typescript + + vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1): + dependencies: + esbuild: 0.25.2 + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.3 + rollup: 4.40.0 + tinyglobby: 0.2.13 + optionalDependencies: + '@types/node': 22.14.1 + fsevents: 2.3.3 + jiti: 1.21.6 + sass: 1.87.0 + yaml: 2.7.1 + + vitest@3.1.2(@types/node@22.14.1)(jiti@1.21.6)(jsdom@26.1.0)(sass@1.87.0)(yaml@2.7.1): + dependencies: + '@vitest/expect': 3.1.2 + '@vitest/mocker': 3.1.2(vite@6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1)) + '@vitest/pretty-format': 3.1.2 + '@vitest/runner': 3.1.2 + '@vitest/snapshot': 3.1.2 + '@vitest/spy': 3.1.2 + '@vitest/utils': 3.1.2 + chai: 5.2.0 + debug: 4.4.0(supports-color@5.5.0) + expect-type: 1.2.1 + magic-string: 0.30.17 + pathe: 2.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 + tinypool: 1.0.2 + tinyrainbow: 2.0.0 + vite: 6.3.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + vite-node: 3.1.2(@types/node@22.14.1)(jiti@1.21.6)(sass@1.87.0)(yaml@2.7.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.14.1 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + wait-on@8.0.3(debug@4.4.0): + dependencies: + axios: 1.8.4(debug@4.4.0) + joi: 17.13.3 + lodash: 4.17.21 + minimist: 1.2.8 + rxjs: 7.8.2 + transitivePeerDependencies: + - debug + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@4.0.2: {} + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + whatwg-url@7.1.0: + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + + which-boxed-primitive@1.0.2: + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + + which-builtin-type@1.1.4: + dependencies: + function.prototype.name: 1.1.6 + has-tostringtag: 1.0.2 + is-async-function: 2.0.0 + is-date-object: 1.0.5 + is-finalizationregistry: 1.0.2 + is-generator-function: 1.0.10 + is-regex: 1.1.4 + is-weakref: 1.0.2 + isarray: 2.0.5 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.2 + which-typed-array: 1.1.15 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.3 + + which-typed-array@1.1.15: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + ws@8.18.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@2.7.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} diff --git a/login/pnpm-workspace.yaml b/login/pnpm-workspace.yaml new file mode 100644 index 0000000000..3ff5faaaf5 --- /dev/null +++ b/login/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/login/scripts/entrypoint.sh b/login/scripts/entrypoint.sh new file mode 100755 index 0000000000..c537e8b8fb --- /dev/null +++ b/login/scripts/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -o allexport +. /.env-file/.env +set +o allexport + +if [ -n "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ] && [ -f "${ZITADEL_SERVICE_USER_TOKEN_FILE}" ]; then + echo "ZITADEL_SERVICE_USER_TOKEN_FILE=${ZITADEL_SERVICE_USER_TOKEN_FILE} is set and file exists, setting ZITADEL_SERVICE_USER_TOKEN to the files content" + export ZITADEL_SERVICE_USER_TOKEN=$(cat "${ZITADEL_SERVICE_USER_TOKEN_FILE}") +fi + +exec node apps/login/server.js diff --git a/login/scripts/healthcheck.js b/login/scripts/healthcheck.js new file mode 100644 index 0000000000..c1a64c6e75 --- /dev/null +++ b/login/scripts/healthcheck.js @@ -0,0 +1,14 @@ +const url = process.argv[2]; + +if (!url) { + console.error("❌ No URL provided as command line argument."); + process.exit(1); +} + +try { + const res = await fetch(url); + if (!res.ok) process.exit(1); + process.exit(0); +} catch (e) { + process.exit(1); +} diff --git a/login/scripts/run_or_skip.sh b/login/scripts/run_or_skip.sh new file mode 100755 index 0000000000..4516eb01b1 --- /dev/null +++ b/login/scripts/run_or_skip.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +# Usage: ./run_or_skip.sh +# Example: ./run_or_skip.sh lint-force "img1;img2" + +set -euo pipefail + +if [ -z "$CACHE_DIR" ]; then + echo "CACHE_DIR is not set. Please set it to a valid directory." + exit 1 +fi + +MAKE_TARGET=$1 +IMAGES=$2 +IGNORE_RUN_CACHE=${IGNORE_RUN_CACHE:-false} + +CACHE_FILE="$CACHE_DIR/$MAKE_TARGET.digests" +mkdir -p "$CACHE_DIR" + +get_image_creation_dates() { + local values="" + for img in $(echo "$IMAGES"); do + local value=$(docker image inspect "$img" --format='{{.Created}}' 2>/dev/null || true) + if [[ -z $value ]]; then + docker pull "$img" >/dev/null 2>&1 || true + value=$(docker image inspect "$img" --format='{{.Created}}' 2>/dev/null || true) + fi + if [[ -z $value ]]; then + value=$(docker image inspect "$img" --format='{{.Created}}' 2>/dev/null || true) + fi + value=${value:-new-and-not-pullable-or-failed-to-build} + value="${img}@${value}" + values="${values}${value};" + done + values=${values%;} # Remove trailing semicolon + echo "$values" +} + +CACHE_FILE_CONTENT=$(cat "$CACHE_FILE" 2>/dev/null || echo "") +CACHED_STATUS=$(echo "$CACHE_FILE_CONTENT" | cut -d ';' -f1) +CACHED_IMAGE_CREATED_VALUES=$(echo "$CACHE_FILE_CONTENT" | cut -d ';' -f2-99) +CURRENT_IMAGE_CREATED_VALUES="$(get_image_creation_dates)" + if [[ "$CACHED_IMAGE_CREATED_VALUES" == "$CURRENT_IMAGE_CREATED_VALUES" ]]; then + if [[ "$IGNORE_RUN_CACHE" == "true" ]]; then + echo "\$IGNORE_RUN_CACHE=$IGNORE_RUN_CACHE - Running $MAKE_TARGET despite unchanged images." + else + echo "Skipping $MAKE_TARGET – all images unchanged, returning cached status $CACHED_STATUS" + exit $CACHED_STATUS + fi +fi +echo "Images have changed" +echo +echo "CACHED_IMAGE_CREATED_VALUES does not match CURRENT_IMAGE_CREATED_VALUES" +echo +echo "$CACHED_IMAGE_CREATED_VALUES" +echo +echo "$CURRENT_IMAGE_CREATED_VALUES" +echo +docker images +echo +echo "Running $MAKE_TARGET..." +set +e +make -j $MAKE_TARGET +STATUS=$? +set -e +echo "${STATUS};$(get_image_creation_dates)" > $CACHE_FILE +exit $STATUS diff --git a/login/turbo.json b/login/turbo.json new file mode 100644 index 0000000000..dabae8fa97 --- /dev/null +++ b/login/turbo.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://turbo.build/schema.json", + "ui": "tui", + "globalDependencies": ["**/.env.*local"], + "globalEnv": [ + "DEBUG", + "VERCEL_URL", + "EMAIL_VERIFICATION", + "AUDIENCE", + "SYSTEM_USER_ID", + "SYSTEM_USER_PRIVATE_KEY", + "ZITADEL_API_URL", + "ZITADEL_SERVICE_USER_TOKEN", + "NEXT_PUBLIC_BASE_PATH", + "CUSTOM_REQUEST_HEADERS", + "NODE_ENV" + ], + "tasks": { + "generate": { + "cache": true + }, + "build": {}, + "build:login:standalone": {}, + "build:client:standalone": {}, + "test": {}, + "start": {}, + "start:built": {}, + "test:unit": {}, + "test:unit:standalone": {}, + "test:integration": {}, + "test:integration:setup": { + "with": ["dev"] + }, + "test:acceptance:setup": {}, + "test:acceptance:setup:dev": { + "with": ["dev"] + }, + "test:watch": { + "persistent": true + }, + "lint": {}, + "lint:fix": {}, + "dev": { + "cache": false, + "persistent": true + }, + "clean": { + "cache": false + } + } +} From a02a534cd227aa4668ff5fdd1cdc352456d2df74 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 2 Jul 2025 11:14:36 +0200 Subject: [PATCH 114/123] feat: initial admin PAT has IAM_LOGIN_CLIENT (#10143) # Which Problems Are Solved We provide a seamless way to initialize Zitadel and the login together. # How the Problems Are Solved Additionally to the `IAM_OWNER` role, a set up admin user also gets the `IAM_LOGIN_CLIENT` role if it is a machine user with a PAT. # Additional Changes - Simplifies the load balancing example, as the intermediate configuration step is not needed anymore. # Additional Context - Depends on #10116 - Contributes to https://github.com/zitadel/zitadel-charts/issues/332 - Contributes to https://github.com/zitadel/zitadel/issues/10016 --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> --- cmd/defaults.yaml | 7 + cmd/setup/03.go | 30 +- cmd/setup/steps.yaml | 8 + .../deploy/loadbalancing-example/.gitignore | 2 +- .../loadbalancing-example/docker-compose.yaml | 24 +- .../example-zitadel-config.yaml | 2 +- .../example-zitadel-init-steps.yaml | 2 +- .../loadbalancing-example.mdx | 2 +- docs/docs/self-hosting/deploy/macos.mdx | 2 +- internal/api/grpc/system/instance.go | 4 +- internal/command/instance.go | 83 ++++-- internal/command/instance_test.go | 268 ++++++++++++++---- internal/command/org.go | 6 + internal/domain/roles.go | 1 + 14 files changed, 337 insertions(+), 104 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 9697e354c5..f1fc6a2414 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -839,6 +839,13 @@ DefaultInstance: Pat: # date format: 2023-01-01T00:00:00Z ExpirationDate: # ZITADEL_DEFAULTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE + LoginClient: + Machine: + Username: # ZITADEL_DEFAULTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME + Name: # ZITADEL_DEFAULTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME + Pat: + # date format: 2023-01-01T00:00:00Z + ExpirationDate: # ZITADEL_DEFAULTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE SecretGenerators: ClientSecret: Length: 64 # ZITADEL_DEFAULTINSTANCE_SECRETGENERATORS_CLIENTSECRET_LENGTH diff --git a/cmd/setup/03.go b/cmd/setup/03.go index 588ac71610..e8c51c79c6 100644 --- a/cmd/setup/03.go +++ b/cmd/setup/03.go @@ -20,12 +20,13 @@ import ( ) type FirstInstance struct { - InstanceName string - DefaultLanguage language.Tag - Org command.InstanceOrgSetup - MachineKeyPath string - PatPath string - Features *command.InstanceFeatures + InstanceName string + DefaultLanguage language.Tag + Org command.InstanceOrgSetup + MachineKeyPath string + PatPath string + LoginClientPatPath string + Features *command.InstanceFeatures Skip bool @@ -121,16 +122,18 @@ func (mig *FirstInstance) Execute(ctx context.Context, _ eventstore.Event) error } } - _, token, key, _, err := cmd.SetUpInstance(ctx, &mig.instanceSetup) + _, token, key, loginClientToken, _, err := cmd.SetUpInstance(ctx, &mig.instanceSetup) if err != nil { return err } - if mig.instanceSetup.Org.Machine != nil && + if (mig.instanceSetup.Org.Machine != nil && ((mig.instanceSetup.Org.Machine.Pat != nil && token == "") || - (mig.instanceSetup.Org.Machine.MachineKey != nil && key == nil)) { + (mig.instanceSetup.Org.Machine.MachineKey != nil && key == nil))) || + (mig.instanceSetup.Org.LoginClient != nil && + (mig.instanceSetup.Org.LoginClient.Pat != nil && loginClientToken == "")) { return err } - return mig.outputMachineAuthentication(key, token) + return mig.outputMachineAuthentication(key, token, loginClientToken) } func (mig *FirstInstance) verifyEncryptionKeys(ctx context.Context) (*crypto_db.Database, error) { @@ -150,7 +153,7 @@ func (mig *FirstInstance) verifyEncryptionKeys(ctx context.Context) (*crypto_db. return keyStorage, nil } -func (mig *FirstInstance) outputMachineAuthentication(key *command.MachineKey, token string) error { +func (mig *FirstInstance) outputMachineAuthentication(key *command.MachineKey, token, loginClientToken string) error { if key != nil { keyDetails, err := key.Detail() if err != nil { @@ -165,6 +168,11 @@ func (mig *FirstInstance) outputMachineAuthentication(key *command.MachineKey, t return err } } + if loginClientToken != "" { + if err := outputStdoutOrPath(mig.LoginClientPatPath, loginClientToken); err != nil { + return err + } + } return nil } diff --git a/cmd/setup/steps.yaml b/cmd/setup/steps.yaml index d2a7cc68dd..709becf2c3 100644 --- a/cmd/setup/steps.yaml +++ b/cmd/setup/steps.yaml @@ -6,6 +6,7 @@ FirstInstance: MachineKeyPath: # ZITADEL_FIRSTINSTANCE_MACHINEKEYPATH # The personal access token from the section FirstInstance.Org.Machine.Pat is written to the PatPath. PatPath: # ZITADEL_FIRSTINSTANCE_PATPATH + LoginClientPatPath: # ZITADEL_FIRSTINSTANCE_LOGINCLIENTPATPATH InstanceName: ZITADEL # ZITADEL_FIRSTINSTANCE_INSTANCENAME DefaultLanguage: en # ZITADEL_FIRSTINSTANCE_DEFAULTLANGUAGE Org: @@ -46,6 +47,13 @@ FirstInstance: Pat: # date format: 2023-01-01T00:00:00Z ExpirationDate: # ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE + LoginClient: + Machine: + Username: # ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_USERNAME + Name: # ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_MACHINE_NAME + Pat: + # date format: 2023-01-01T00:00:00Z + ExpirationDate: # ZITADEL_FIRSTINSTANCE_ORG_LOGINCLIENT_PAT_EXPIRATIONDATE CorrectCreationDate: FailAfter: 5m # ZITADEL_CORRECTCREATIONDATE_FAILAFTER diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore b/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore index bd98bacd66..8a28618b17 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/.gitignore @@ -1 +1 @@ -.env-file +.env-file \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml index 013fc2aa22..96a87fa8d7 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/docker-compose.yaml @@ -41,17 +41,17 @@ services: user: root entrypoint: '/bin/sh' command: - - -c - - > - /app/zitadel setup - --config /example-zitadel-config.yaml - --config /example-zitadel-secrets.yaml - --steps /example-zitadel-init-steps.yaml - --masterkey ${ZITADEL_MASTERKEY} && - mv /pat /.env-file/pat || exit 0 && - echo ZITADEL_SERVICE_USER_TOKEN=$(cat /.env-file/pat) > /.env-file/.env && - chown -R 1001:${GID} /.env-file && - chmod -R 770 /.env-file + - -c + - > + /app/zitadel setup + --config /example-zitadel-config.yaml + --config /example-zitadel-secrets.yaml + --steps /example-zitadel-init-steps.yaml + --masterkey ${ZITADEL_MASTERKEY} && + mv /pat /.env-file/pat || exit 0 && + echo ZITADEL_SERVICE_USER_TOKEN=$(cat /.env-file/pat) > /.env-file/.env && + chown -R 1001:${GID} /.env-file && + chmod -R 770 /.env-file environment: - GID depends_on: @@ -154,4 +154,4 @@ networks: backend: volumes: - data: + data: \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml index fadd39373d..af5bb5145c 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-config.yaml @@ -26,4 +26,4 @@ SAML.DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_SAML_DEFAULT LogStore.Access.Stdout.Enabled: true # Skipping the MFA init step allows us to immediately authenticate at the console -DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" +DefaultInstance.LoginPolicy.MfaInitSkipLifetime: "0s" \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml index be63164ced..9bdf41269d 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/example-zitadel-init-steps.yaml @@ -9,4 +9,4 @@ FirstInstance: Machine: Username: 'login-container' Name: 'Login Container' - Pat.ExpirationDate: '2029-01-01T00:00:00Z' + Pat.ExpirationDate: '2029-01-01T00:00:00Z' \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx index d4c27ccd95..3fb4784ea0 100644 --- a/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx +++ b/docs/docs/self-hosting/deploy/loadbalancing-example/loadbalancing-example.mdx @@ -71,4 +71,4 @@ Open your favorite internet browser at https://127.0.0.1.sslip.io/ui/console?log Your browser warns you about the insecure self-signed TLS certificate. As 127.0.0.1.sslip.io resolves to your localhost, you can safely proceed. Use the password *Password1!* to log in. -Read more about [the login process](/guides/integrate/login/oidc/login-users). +Read more about [the login process](/guides/integrate/login/oidc/login-users). \ No newline at end of file diff --git a/docs/docs/self-hosting/deploy/macos.mdx b/docs/docs/self-hosting/deploy/macos.mdx index beb3182208..aea5fb07e9 100644 --- a/docs/docs/self-hosting/deploy/macos.mdx +++ b/docs/docs/self-hosting/deploy/macos.mdx @@ -64,4 +64,4 @@ mv /tmp/zitadel-admin-sa.json $HOME/zitadel-admin-sa.json This key can be used to provision resources with for example [Terraform](/docs/guides/manage/terraform-provider). - + \ No newline at end of file diff --git a/internal/api/grpc/system/instance.go b/internal/api/grpc/system/instance.go index a5dd7b81bc..ccfcfecbf3 100644 --- a/internal/api/grpc/system/instance.go +++ b/internal/api/grpc/system/instance.go @@ -40,7 +40,7 @@ func (s *Server) GetInstance(ctx context.Context, req *system_pb.GetInstanceRequ } func (s *Server) AddInstance(ctx context.Context, req *system_pb.AddInstanceRequest) (*system_pb.AddInstanceResponse, error) { - id, _, _, details, err := s.command.SetUpInstance(ctx, AddInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) + id, _, _, _, details, err := s.command.SetUpInstance(ctx, AddInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) if err != nil { return nil, err } @@ -61,7 +61,7 @@ func (s *Server) UpdateInstance(ctx context.Context, req *system_pb.UpdateInstan } func (s *Server) CreateInstance(ctx context.Context, req *system_pb.CreateInstanceRequest) (*system_pb.CreateInstanceResponse, error) { - id, pat, key, details, err := s.command.SetUpInstance(ctx, CreateInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) + id, pat, key, _, details, err := s.command.SetUpInstance(ctx, CreateInstancePbToSetupInstance(req, s.defaultInstance, s.externalDomain)) if err != nil { return nil, err } diff --git a/internal/command/instance.go b/internal/command/instance.go index cfafb1d298..9e8f3d47c7 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -217,33 +217,33 @@ func (s *InstanceSetup) generateIDs(idGenerator id.Generator) (err error) { return err } -func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (string, string, *MachineKey, *domain.ObjectDetails, error) { +func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (string, string, *MachineKey, string, *domain.ObjectDetails, error) { if err := setup.generateIDs(c.idGenerator); err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } ctx = contextWithInstanceSetupInfo(ctx, setup.zitadel.instanceID, setup.zitadel.projectID, setup.zitadel.consoleAppID, c.externalDomain, setup.DefaultLanguage) - validations, pat, machineKey, err := setUpInstance(ctx, c, setup) + validations, pat, machineKey, loginClientPat, err := setUpInstance(ctx, c, setup) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } //nolint:staticcheck cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validations...) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } _, err = c.eventstore.Push(ctx, cmds...) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } // RolePermissions need to be pushed in separate transaction. // https://github.com/zitadel/zitadel/issues/9293 details, err := c.SynchronizeRolePermission(ctx, setup.zitadel.instanceID, setup.RolePermissionMappings) if err != nil { - return "", "", nil, nil, err + return "", "", nil, "", nil, err } details.ResourceOwner = setup.zitadel.orgID @@ -251,8 +251,12 @@ func (c *Commands) SetUpInstance(ctx context.Context, setup *InstanceSetup) (str if pat != nil { token = pat.Token } + var loginClientToken string + if loginClientPat != nil { + loginClientToken = loginClientPat.Token + } - return setup.zitadel.instanceID, token, machineKey, details, nil + return setup.zitadel.instanceID, token, machineKey, loginClientToken, details, nil } func contextWithInstanceSetupInfo(ctx context.Context, instanceID, projectID, consoleAppID, externalDomain string, defaultLanguage language.Tag) context.Context { @@ -274,38 +278,38 @@ func contextWithInstanceSetupInfo(ctx context.Context, instanceID, projectID, co ) } -func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (validations []preparation.Validation, pat *PersonalAccessToken, machineKey *MachineKey, err error) { +func setUpInstance(ctx context.Context, c *Commands, setup *InstanceSetup) (validations []preparation.Validation, pat *PersonalAccessToken, machineKey *MachineKey, loginClientPat *PersonalAccessToken, err error) { instanceAgg := instance.NewAggregate(setup.zitadel.instanceID) validations = setupInstanceElements(instanceAgg, setup) // default organization on setup'd instance - pat, machineKey, err = setupDefaultOrg(ctx, c, &validations, instanceAgg, setup.Org.Name, setup.Org.Machine, setup.Org.Human, setup.zitadel) + pat, machineKey, loginClientPat, err = setupDefaultOrg(ctx, c, &validations, instanceAgg, setup.Org.Name, setup.Org.Machine, setup.Org.Human, setup.Org.LoginClient, setup.zitadel) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } // domains if err := setupGeneratedDomain(ctx, c, &validations, instanceAgg, setup.InstanceName); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } setupCustomDomain(c, &validations, instanceAgg, setup.CustomDomain) // optional setting if set setupMessageTexts(&validations, setup.MessageTexts, instanceAgg) if err := setupQuotas(c, &validations, setup.Quotas, setup.zitadel.instanceID); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } setupSMTPSettings(c, &validations, setup.SMTPConfiguration, instanceAgg) if err := setupWebKeys(c, &validations, setup.zitadel.instanceID, setup); err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } setupOIDCSettings(c, &validations, setup.OIDCSettings, instanceAgg) setupFeatures(&validations, setup.Features, setup.zitadel.instanceID) setupLimits(c, &validations, limits.NewAggregate(setup.zitadel.limitsID, setup.zitadel.instanceID), setup.Limits) setupRestrictions(c, &validations, restrictions.NewAggregate(setup.zitadel.restrictionsID, setup.zitadel.instanceID, setup.zitadel.instanceID), setup.Restrictions) setupInstanceCreatedMilestone(&validations, setup.zitadel.instanceID) - return validations, pat, machineKey, nil + return validations, pat, machineKey, loginClientPat, nil } func setupInstanceElements(instanceAgg *instance.Aggregate, setup *InstanceSetup) []preparation.Validation { @@ -572,8 +576,9 @@ func setupDefaultOrg(ctx context.Context, name string, machine *AddMachine, human *AddHuman, + loginClient *AddLoginClient, ids ZitadelConfig, -) (pat *PersonalAccessToken, machineKey *MachineKey, err error) { +) (pat *PersonalAccessToken, machineKey *MachineKey, loginClientPat *PersonalAccessToken, err error) { orgAgg := org.NewAggregate(ids.orgID) *validations = append( @@ -582,12 +587,12 @@ func setupDefaultOrg(ctx context.Context, commands.prepareSetDefaultOrg(instanceAgg, ids.orgID), ) - projectOwner, pat, machineKey, err := setupAdmins(commands, validations, instanceAgg, orgAgg, machine, human) + projectOwner, pat, machineKey, loginClientPat, err := setupAdmins(commands, validations, instanceAgg, orgAgg, machine, human, loginClient) if err != nil { - return nil, nil, err + return nil, nil, nil, err } setupMinimalInterfaces(commands, validations, instanceAgg, orgAgg, projectOwner, ids) - return pat, machineKey, nil + return pat, machineKey, loginClientPat, nil } func setupAdmins(commands *Commands, @@ -596,21 +601,22 @@ func setupAdmins(commands *Commands, orgAgg *org.Aggregate, machine *AddMachine, human *AddHuman, -) (owner string, pat *PersonalAccessToken, machineKey *MachineKey, err error) { + loginClient *AddLoginClient, +) (owner string, pat *PersonalAccessToken, machineKey *MachineKey, loginClientPat *PersonalAccessToken, err error) { if human == nil && machine == nil { - return "", nil, nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-z1yi2q2ot7", "Error.Instance.NoAdmin") + return "", nil, nil, nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-z1yi2q2ot7", "Error.Instance.NoAdmin") } if machine != nil && machine.Machine != nil && !machine.Machine.IsZero() { machineUserID, err := commands.idGenerator.Next() if err != nil { - return "", nil, nil, err + return "", nil, nil, nil, err } owner = machineUserID pat, machineKey, err = setupMachineAdmin(commands, validations, machine, orgAgg.ID, machineUserID) if err != nil { - return "", nil, nil, err + return "", nil, nil, nil, err } setupAdminMembers(commands, validations, instanceAgg, orgAgg, machineUserID) @@ -618,7 +624,7 @@ func setupAdmins(commands *Commands, if human != nil { humanUserID, err := commands.idGenerator.Next() if err != nil { - return "", nil, nil, err + return "", nil, nil, nil, err } owner = humanUserID human.ID = humanUserID @@ -629,7 +635,18 @@ func setupAdmins(commands *Commands, setupAdminMembers(commands, validations, instanceAgg, orgAgg, humanUserID) } - return owner, pat, machineKey, nil + if loginClient != nil { + loginClientUserID, err := commands.idGenerator.Next() + if err != nil { + return "", nil, nil, nil, err + } + + loginClientPat, err = setupLoginClient(commands, validations, instanceAgg, loginClient, orgAgg.ID, loginClientUserID) + if err != nil { + return "", nil, nil, nil, err + } + } + return owner, pat, machineKey, loginClientPat, nil } func setupMachineAdmin(commands *Commands, validations *[]preparation.Validation, machine *AddMachine, orgID, userID string) (pat *PersonalAccessToken, machineKey *MachineKey, err error) { @@ -655,6 +672,22 @@ func setupMachineAdmin(commands *Commands, validations *[]preparation.Validation return pat, machineKey, nil } +func setupLoginClient(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, loginClient *AddLoginClient, orgID, userID string) (pat *PersonalAccessToken, err error) { + *validations = append(*validations, + AddMachineCommand(user.NewAggregate(userID, orgID), loginClient.Machine), + commands.AddInstanceMemberCommand(instanceAgg, userID, domain.RoleIAMLoginClient), + ) + if loginClient.Pat != nil { + pat = NewPersonalAccessToken(orgID, userID, loginClient.Pat.ExpirationDate, loginClient.Pat.Scopes, domain.UserTypeMachine) + pat.TokenID, err = commands.idGenerator.Next() + if err != nil { + return nil, err + } + *validations = append(*validations, prepareAddPersonalAccessToken(pat, commands.keyAlgorithm)) + } + return pat, nil +} + func setupAdminMembers(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, orgAgg *org.Aggregate, userID string) { *validations = append(*validations, commands.AddOrgMemberCommand(orgAgg, userID, domain.RoleOrgOwner), diff --git a/internal/command/instance_test.go b/internal/command/instance_test.go index 2b82818a7e..b40bba19af 100644 --- a/internal/command/instance_test.go +++ b/internal/command/instance_test.go @@ -129,7 +129,7 @@ func oidcAppEvents(ctx context.Context, orgID, projectID, id, name, clientID str } } -func orgFilters(orgID string, machine, human bool) []expect { +func orgFilters(orgID string, machine, human, loginClient bool) []expect { filters := []expect{ expectFilter(), expectFilter( @@ -144,13 +144,17 @@ func orgFilters(orgID string, machine, human bool) []expect { filters = append(filters, humanFilters(orgID)...) filters = append(filters, adminMemberFilters(orgID, "USER")...) } + if loginClient { + filters = append(filters, loginClientFilters(orgID, true)...) + filters = append(filters, instanceMemberFilters(orgID, "USER-LOGIN-CLIENT")...) + } return append(filters, projectFilters()..., ) } -func orgEvents(ctx context.Context, instanceID, orgID, name, projectID, defaultDomain string, externalSecure bool, machine, human bool) []eventstore.Command { +func orgEvents(ctx context.Context, instanceID, orgID, name, projectID, defaultDomain string, externalSecure bool, machine, human, loginClient bool) []eventstore.Command { instanceAgg := instance.NewAggregate(instanceID) orgAgg := org.NewAggregate(orgID) domain := strings.ToLower(name + "." + defaultDomain) @@ -173,13 +177,17 @@ func orgEvents(ctx context.Context, instanceID, orgID, name, projectID, defaultD events = append(events, humanEvents(ctx, instanceID, orgID, userID)...) owner = userID } + if loginClient { + userID := "USER-LOGIN-CLIENT" + events = append(events, loginClientEvents(ctx, instanceID, orgID, userID, "LOGIN-CLIENT-PAT")...) + } events = append(events, projectAddedEvents(ctx, instanceID, orgID, projectID, owner, externalSecure)...) return events } func orgIDs() []string { - return slices.Concat([]string{"USER-MACHINE", "PAT", "USER"}, projectClientIDs()) + return slices.Concat([]string{"USER-MACHINE", "PAT", "USER", "USER-LOGIN-CLIENT", "LOGIN-CLIENT-PAT"}, projectClientIDs()) } func instancePoliciesFilters(instanceID string) []expect { @@ -363,7 +371,7 @@ func instanceElementsConfig() *SecretGenerators { func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string) []expect { return slices.Concat( setupInstanceElementsFilters(instanceID), - orgFilters(orgID, true, true), + orgFilters(orgID, true, true, true), generatedDomainFilters(instanceID, orgID, projectID, appID, domain), ) } @@ -371,7 +379,7 @@ func setupInstanceFilters(instanceID, orgID, projectID, appID, domain string) [] func setupInstanceEvents(ctx context.Context, instanceID, orgID, projectID, appID, instanceName, orgName string, defaultLanguage language.Tag, domain string, externalSecure bool) []eventstore.Command { return slices.Concat( setupInstanceElementsEvents(ctx, instanceID, instanceName, defaultLanguage), - orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true), + orgEvents(ctx, instanceID, orgID, orgName, projectID, domain, externalSecure, true, true, true), generatedDomainEvents(ctx, instanceID, orgID, projectID, appID, domain), instanceCreatedMilestoneEvent(ctx, instanceID), ) @@ -380,9 +388,10 @@ func setupInstanceEvents(ctx context.Context, instanceID, orgID, projectID, appI func setupInstanceConfig() *InstanceSetup { conf := setupInstanceElementsConfig() conf.Org = InstanceOrgSetup{ - Name: "ZITADEL", - Machine: instanceSetupMachineConfig(), - Human: instanceSetupHumanConfig(), + Name: "ZITADEL", + Machine: instanceSetupMachineConfig(), + Human: instanceSetupHumanConfig(), + LoginClient: instanceSetupLoginClientConfig(), } conf.CustomDomain = "" return conf @@ -541,6 +550,43 @@ func instanceSetupMachineConfig() *AddMachine { } } +func loginClientFilters(orgID string, pat bool) []expect { + filters := []expect{ + expectFilter(), + expectFilter( + org.NewDomainPolicyAddedEvent( + context.Background(), + &org.NewAggregate(orgID).Aggregate, + true, + true, + true, + ), + ), + } + if pat { + filters = append(filters, + expectFilter(), + expectFilter(), + ) + } + return filters +} + +func instanceSetupLoginClientConfig() *AddLoginClient { + return &AddLoginClient{ + Machine: &Machine{ + Username: "zitadel-login-client", + Name: "ZITADEL-login-client", + Description: "Login Client", + AccessTokenType: domain.OIDCTokenTypeBearer, + }, + Pat: &AddPat{ + ExpirationDate: time.Time{}, + Scopes: nil, + }, + } +} + func projectFilters() []expect { return []expect{ expectFilter(), @@ -551,11 +597,23 @@ func projectFilters() []expect { } func adminMemberFilters(orgID, userID string) []expect { + filters := append( + orgMemberFilters(orgID, userID), + instanceMemberFilters(orgID, userID)..., + ) + return filters +} +func orgMemberFilters(orgID, userID string) []expect { return []expect{ expectFilter( addHumanEvent(context.Background(), orgID, userID), ), expectFilter(), + } +} + +func instanceMemberFilters(orgID, userID string) []expect { + return []expect{ expectFilter( addHumanEvent(context.Background(), orgID, userID), ), @@ -631,6 +689,40 @@ func addMachineEvent(ctx context.Context, orgID, userID string) *user.MachineAdd ) } +// loginClientEvents all events from setup to create the login client user +func loginClientEvents(ctx context.Context, instanceID, orgID, userID, patID string) []eventstore.Command { + agg := user.NewAggregate(userID, orgID) + instanceAgg := instance.NewAggregate(instanceID) + events := []eventstore.Command{ + addLoginClientEvent(ctx, orgID, userID), + instance.NewMemberAddedEvent(ctx, &instanceAgg.Aggregate, userID, domain.RoleIAMLoginClient), + } + if patID != "" { + events = append(events, + user.NewPersonalAccessTokenAddedEvent( + ctx, + &agg.Aggregate, + patID, + time.Date(9999, time.December, 31, 23, 59, 59, 0, time.UTC), + nil, + ), + ) + } + return events +} + +func addLoginClientEvent(ctx context.Context, orgID, userID string) *user.MachineAddedEvent { + agg := user.NewAggregate(userID, orgID) + return user.NewMachineAddedEvent(ctx, + &agg.Aggregate, + "zitadel-login-client", + "ZITADEL-login-client", + "Login Client", + false, + domain.OIDCTokenTypeBearer, + ) +} + func testSetup(ctx context.Context, c *Commands, validations []preparation.Validation) error { //nolint:staticcheck cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, validations...) @@ -715,6 +807,13 @@ func TestCommandSide_setupMinimalInterfaces(t *testing.T) { }) } } +func validZitadelRoles() []authz.RoleMapping { + return []authz.RoleMapping{ + {Role: domain.RoleOrgOwner, Permissions: []string{""}}, + {Role: domain.RoleIAMOwner, Permissions: []string{""}}, + {Role: domain.RoleIAMLoginClient, Permissions: []string{""}}, + } +} func TestCommandSide_setupAdmins(t *testing.T) { type fields struct { @@ -730,12 +829,14 @@ func TestCommandSide_setupAdmins(t *testing.T) { orgAgg *org.Aggregate machine *AddMachine human *AddHuman + loginClient *AddLoginClient } type res struct { - owner string - pat bool - machineKey bool - err func(error) bool + owner string + pat bool + machineKey bool + loginClientPat bool + err func(error) bool } tests := []struct { name string @@ -763,10 +864,7 @@ func TestCommandSide_setupAdmins(t *testing.T) { ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER"), userPasswordHasher: mockPasswordHasher("x"), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, + roles: validZitadelRoles(), }, args: args{ ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), @@ -800,11 +898,8 @@ func TestCommandSide_setupAdmins(t *testing.T) { }, )..., ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT"), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT"), + roles: validZitadelRoles(), keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ @@ -850,11 +945,8 @@ func TestCommandSide_setupAdmins(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT", "USER"), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, - keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), @@ -870,6 +962,63 @@ func TestCommandSide_setupAdmins(t *testing.T) { err: nil, }, }, + { + name: "human, machine and login client, ok", + fields: fields{ + eventstore: expectEventstore( + slices.Concat( + machineFilters("ORG", true), + adminMemberFilters("ORG", "USER-MACHINE"), + humanFilters("ORG"), + adminMemberFilters("ORG", "USER"), + loginClientFilters("ORG", true), + instanceMemberFilters("ORG", "USER-LOGIN-CLIENT"), + []expect{ + expectPush( + slices.Concat( + machineEvents(context.Background(), + "INSTANCE", + "ORG", + "USER-MACHINE", + "PAT", + ), + humanEvents(context.Background(), + "INSTANCE", + "ORG", + "USER", + ), + loginClientEvents(context.Background(), + "INSTANCE", + "ORG", + "USER-LOGIN-CLIENT", + "LOGIN-CLIENT-PAT", + ), + )..., + ), + }, + )..., + ), + userPasswordHasher: mockPasswordHasher("x"), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "USER-MACHINE", "PAT", "USER", "USER-LOGIN-CLIENT", "LOGIN-CLIENT-PAT"), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), + instanceAgg: instance.NewAggregate("INSTANCE"), + orgAgg: org.NewAggregate("ORG"), + machine: instanceSetupMachineConfig(), + human: instanceSetupHumanConfig(), + loginClient: instanceSetupLoginClientConfig(), + }, + res: res{ + owner: "USER", + pat: true, + machineKey: false, + loginClientPat: true, + err: nil, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -881,7 +1030,7 @@ func TestCommandSide_setupAdmins(t *testing.T) { keyAlgorithm: tt.fields.keyAlgorithm, } validations := make([]preparation.Validation, 0) - owner, pat, mk, err := setupAdmins(r, &validations, tt.args.instanceAgg, tt.args.orgAgg, tt.args.machine, tt.args.human) + owner, pat, mk, loginClientPat, err := setupAdmins(r, &validations, tt.args.instanceAgg, tt.args.orgAgg, tt.args.machine, tt.args.human, tt.args.loginClient) if tt.res.err == nil { assert.NoError(t, err) } @@ -905,6 +1054,9 @@ func TestCommandSide_setupAdmins(t *testing.T) { if tt.res.machineKey { assert.NotNil(t, mk) } + if tt.res.loginClientPat { + assert.NotNil(t, loginClientPat) + } } }) } @@ -924,12 +1076,14 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { orgName string machine *AddMachine human *AddHuman + loginClient *AddLoginClient ids ZitadelConfig } type res struct { - pat bool - machineKey bool - err func(error) bool + pat bool + machineKey bool + loginClientPat bool + err func(error) bool } tests := []struct { name string @@ -938,7 +1092,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { res res }{ { - name: "human and machine, ok", + name: "human, machine and login client, ok", fields: fields{ eventstore: expectEventstore( slices.Concat( @@ -946,6 +1100,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { "ORG", true, true, + true, ), []expect{ expectPush( @@ -959,6 +1114,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { false, true, true, + true, ), )..., ), @@ -967,11 +1123,8 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, orgIDs()...), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, - keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), }, args: args{ ctx: contextWithInstanceSetupInfo(context.Background(), "INSTANCE", "PROJECT", "console-id", "DOMAIN", language.Dutch), @@ -1007,6 +1160,18 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { Password: "password", PasswordChangeRequired: false, }, + loginClient: &AddLoginClient{ + Machine: &Machine{ + Username: "zitadel-login-client", + Name: "ZITADEL-login-client", + Description: "Login Client", + AccessTokenType: domain.OIDCTokenTypeBearer, + }, + Pat: &AddPat{ + ExpirationDate: time.Time{}, + Scopes: nil, + }, + }, ids: ZitadelConfig{ instanceID: "INSTANCE", orgID: "ORG", @@ -1018,9 +1183,10 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { }, }, res: res{ - pat: true, - machineKey: false, - err: nil, + pat: true, + machineKey: false, + loginClientPat: true, + err: nil, }, }, } @@ -1034,7 +1200,7 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { keyAlgorithm: tt.fields.keyAlgorithm, } validations := make([]preparation.Validation, 0) - pat, mk, err := setupDefaultOrg(tt.args.ctx, r, &validations, tt.args.instanceAgg, tt.args.orgName, tt.args.machine, tt.args.human, tt.args.ids) + pat, mk, loginClientPat, err := setupDefaultOrg(tt.args.ctx, r, &validations, tt.args.instanceAgg, tt.args.orgName, tt.args.machine, tt.args.human, tt.args.loginClient, tt.args.ids) if tt.res.err == nil { assert.NoError(t, err) } @@ -1057,6 +1223,9 @@ func TestCommandSide_setupDefaultOrg(t *testing.T) { if tt.res.machineKey { assert.NotNil(t, mk) } + if tt.res.loginClientPat { + assert.NotNil(t, loginClientPat) + } } }) } @@ -1140,9 +1309,10 @@ func TestCommandSide_setUpInstance(t *testing.T) { setup *InstanceSetup } type res struct { - pat bool - machineKey bool - err func(error) bool + pat bool + machineKey bool + loginClientPat bool + err func(error) bool } tests := []struct { name string @@ -1175,11 +1345,8 @@ func TestCommandSide_setUpInstance(t *testing.T) { ), userPasswordHasher: mockPasswordHasher("x"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, orgIDs()...), - roles: []authz.RoleMapping{ - {Role: domain.RoleOrgOwner, Permissions: []string{""}}, - {Role: domain.RoleIAMOwner, Permissions: []string{""}}, - }, - keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + roles: validZitadelRoles(), + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), generateDomain: func(string, string) (string, error) { return "DOMAIN", nil }, @@ -1204,7 +1371,7 @@ func TestCommandSide_setUpInstance(t *testing.T) { GenerateDomain: tt.fields.generateDomain, } - validations, pat, mk, err := setUpInstance(tt.args.ctx, r, tt.args.setup) + validations, pat, mk, loginClientPat, err := setUpInstance(tt.args.ctx, r, tt.args.setup) if tt.res.err == nil { assert.NoError(t, err) } @@ -1227,6 +1394,9 @@ func TestCommandSide_setUpInstance(t *testing.T) { if tt.res.machineKey { assert.NotNil(t, mk) } + if tt.res.loginClientPat { + assert.NotNil(t, loginClientPat) + } } }) } diff --git a/internal/command/org.go b/internal/command/org.go index faab882d68..876c256a0a 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -24,9 +24,15 @@ type InstanceOrgSetup struct { CustomDomain string Human *AddHuman Machine *AddMachine + LoginClient *AddLoginClient Roles []string } +type AddLoginClient struct { + Machine *Machine + Pat *AddPat +} + type OrgSetup struct { Name string CustomDomain string diff --git a/internal/domain/roles.go b/internal/domain/roles.go index c40eef6120..2cebd26d30 100644 --- a/internal/domain/roles.go +++ b/internal/domain/roles.go @@ -14,6 +14,7 @@ const ( RoleOrgOwner = "ORG_OWNER" RoleOrgProjectCreator = "ORG_PROJECT_CREATOR" RoleIAMOwner = "IAM_OWNER" + RoleIAMLoginClient = "IAM_LOGIN_CLIENT" RoleProjectOwner = "PROJECT_OWNER" RoleProjectOwnerGlobal = "PROJECT_OWNER_GLOBAL" RoleProjectGrantOwner = "PROJECT_GRANT_OWNER" From 325aa1f184c6eaa7ad631e10d4acd2bfbf22fbbf Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 2 Jul 2025 11:43:19 +0200 Subject: [PATCH 115/123] fix(login): ensure correct i18n locale context (#10156) This PR ensures that the correct locale context is set for the new login --- login/apps/login/src/i18n/request.ts | 30 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/login/apps/login/src/i18n/request.ts b/login/apps/login/src/i18n/request.ts index 9e5e37e231..15cfe01548 100644 --- a/login/apps/login/src/i18n/request.ts +++ b/login/apps/login/src/i18n/request.ts @@ -15,6 +15,21 @@ export default getRequestConfig(async () => { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); + const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); + if (languageHeader) { + const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code + if (LANGS.map((l) => l.code).includes(headerLocale)) { + locale = headerLocale; + } + } + + const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME); + if (languageCookie && languageCookie.value) { + if (LANGS.map((l) => l.code).includes(languageCookie.value)) { + locale = languageCookie.value; + } + } + const i18nOrganization = _headers.get("x-zitadel-i18n-organization") || ""; // You may need to set this header in middleware let translations: JsonObject | {} = {}; @@ -32,21 +47,6 @@ export default getRequestConfig(async () => { console.warn("Error fetching custom translations:", error); } - const languageHeader = await (await headers()).get(LANGUAGE_HEADER_NAME); - if (languageHeader) { - const headerLocale = languageHeader.split(",")[0].split("-")[0]; // Extract the language code - if (LANGS.map((l) => l.code).includes(headerLocale)) { - locale = headerLocale; - } - } - - const languageCookie = cookiesList?.get(LANGUAGE_COOKIE_NAME); - if (languageCookie && languageCookie.value) { - if (LANGS.map((l) => l.code).includes(languageCookie.value)) { - locale = languageCookie.value; - } - } - const customMessages = translations; const localeMessages = (await import(`../../locales/${locale}.json`)).default; const fallbackMessages = (await import(`../../locales/${fallback}.json`)) From 71575e8d67e75495a990073ccff1d0e89e239181 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 2 Jul 2025 07:04:59 -0400 Subject: [PATCH 116/123] fix(webauthn): allow to use "old" passkeys/u2f credentials on session API (#10150) # Which Problems Are Solved To prevent presenting unusable WebAuthN credentials to the user / browser, we filtered out all credentials, which do not match the requested RP ID. Since credentials set up through Login V1 and Console do not have an RP ID stored, they never matched. This was previously intended, since the Login V2 could be served on a separate domain. The problem is, that if it is hosted on the same domain, the credentials would also be filtered out and user would not be able to login. # How the Problems Are Solved Change the filtering to return credentials, if no RP ID is stored and the requested RP ID matches the instance domain. # Additional Changes None # Additional Context Noted internally when testing the login v2 --- internal/webauthn/converter.go | 14 ++- internal/webauthn/converter_test.go | 153 ++++++++++++++++++++++++++++ internal/webauthn/webauthn.go | 6 +- 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 internal/webauthn/converter_test.go diff --git a/internal/webauthn/converter.go b/internal/webauthn/converter.go index 36799ee3dc..c914bb8bf9 100644 --- a/internal/webauthn/converter.go +++ b/internal/webauthn/converter.go @@ -1,16 +1,26 @@ package webauthn import ( + "context" + "strings" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/domain" ) -func WebAuthNsToCredentials(webAuthNs []*domain.WebAuthNToken, rpID string) []webauthn.Credential { +func WebAuthNsToCredentials(ctx context.Context, webAuthNs []*domain.WebAuthNToken, rpID string) []webauthn.Credential { creds := make([]webauthn.Credential, 0) for _, webAuthN := range webAuthNs { - if webAuthN.State == domain.MFAStateReady && webAuthN.RPID == rpID { + // only add credentials that are ready and + // either match the rpID or + // if they were added through Console / old login UI, there is no stored rpID set; + // then we check if the requested rpID matches the instance domain + if webAuthN.State == domain.MFAStateReady && + (webAuthN.RPID == rpID || + (webAuthN.RPID == "" && rpID == strings.Split(http.DomainContext(ctx).InstanceHost, ":")[0])) { creds = append(creds, webauthn.Credential{ ID: webAuthN.KeyID, PublicKey: webAuthN.PublicKey, diff --git a/internal/webauthn/converter_test.go b/internal/webauthn/converter_test.go new file mode 100644 index 0000000000..a8f2a3608b --- /dev/null +++ b/internal/webauthn/converter_test.go @@ -0,0 +1,153 @@ +package webauthn + +import ( + "context" + "testing" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/domain" +) + +func TestWebAuthNsToCredentials(t *testing.T) { + type args struct { + ctx context.Context + webAuthNs []*domain.WebAuthNToken + rpID string + } + tests := []struct { + name string + args args + want []webauthn.Credential + }{ + { + name: "unready credential", + args: args{ + ctx: context.Background(), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateNotReady, + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "not matching rpID", + args: args{ + ctx: context.Background(), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "other.com", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "matching rpID", + args: args{ + ctx: context.Background(), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "example.com", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{ + { + ID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("aaguid1"), + SignCount: 1, + }, + }, + }, + }, + { + name: "no rpID, different host", + args: args{ + ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{ + InstanceHost: "other.com:443", + PublicHost: "other.com:443", + Protocol: "https", + }), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "no rpID, same host", + args: args{ + ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{ + InstanceHost: "example.com:443", + PublicHost: "example.com:443", + Protocol: "https", + }), + webAuthNs: []*domain.WebAuthNToken{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AAGUID: []byte("aaguid1"), + SignCount: 1, + State: domain.MFAStateReady, + RPID: "", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{ + { + ID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("aaguid1"), + SignCount: 1, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, WebAuthNsToCredentials(tt.args.ctx, tt.args.webAuthNs, tt.args.rpID), "WebAuthNsToCredentials(%v, %v, %v)", tt.args.ctx, tt.args.webAuthNs, tt.args.rpID) + }) + } +} diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index 998c013a3c..10d6fc52bf 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -57,7 +57,7 @@ func (w *Config) BeginRegistration(ctx context.Context, user *domain.Human, acco if err != nil { return nil, err } - creds := WebAuthNsToCredentials(webAuthNs, rpID) + creds := WebAuthNsToCredentials(ctx, webAuthNs, rpID) existing := make([]protocol.CredentialDescriptor, len(creds)) for i, cred := range creds { existing[i] = protocol.CredentialDescriptor{ @@ -136,7 +136,7 @@ func (w *Config) BeginLogin(ctx context.Context, user *domain.Human, userVerific } assertion, sessionData, err := webAuthNServer.BeginLogin(&webUser{ Human: user, - credentials: WebAuthNsToCredentials(webAuthNs, rpID), + credentials: WebAuthNsToCredentials(ctx, webAuthNs, rpID), }, webauthn.WithUserVerification(UserVerificationFromDomain(userVerification))) if err != nil { logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn login could not be started") @@ -163,7 +163,7 @@ func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN * } webUser := &webUser{ Human: user, - credentials: WebAuthNsToCredentials(webAuthNs, webAuthN.RPID), + credentials: WebAuthNsToCredentials(ctx, webAuthNs, webAuthN.RPID), } webAuthNServer, err := w.serverFromContext(ctx, webAuthN.RPID, assertionData.Response.CollectedClientData.Origin) if err != nil { From f93a35c7a8817e227009f26722d3221361295c08 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Wed, 2 Jul 2025 07:57:41 -0400 Subject: [PATCH 117/123] feat: implement service ping (#10080) This PR is still WIP and needs changes to at least the tests. # Which Problems Are Solved To be able to report analytical / telemetry data from deployed Zitadel systems back to a central endpoint, we designed a "service ping" functionality. See also https://github.com/zitadel/zitadel/issues/9706. This PR adds the first implementation to allow collection base data as well as report amount of resources such as organizations, users per organization and more. # How the Problems Are Solved - Added a worker to handle the different `ReportType` variations. - Schedule a periodic job to start a `ServicePingReport` - Configuration added to allow customization of what data will be reported - Setup step to generate and store a `systemID` # Additional Changes None # Additional Context relates to #9869 --- cmd/defaults.yaml | 31 + cmd/setup/60.go | 27 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + cmd/start/config.go | 2 + cmd/start/start.go | 11 + go.mod | 1 + internal/queue/queue.go | 21 + internal/serviceping/client.go | 153 +++ internal/serviceping/config.go | 18 + internal/serviceping/mock/mock_gen.go | 5 + internal/serviceping/mock/queries.mock.go | 72 ++ internal/serviceping/mock/queue.mock.go | 62 ++ internal/serviceping/mock/telemetry.mock.go | 83 ++ internal/serviceping/report.go | 17 + internal/serviceping/worker.go | 252 +++++ internal/serviceping/worker_test.go | 1052 +++++++++++++++++++ internal/v2/system/event.go | 44 + 18 files changed, 1854 insertions(+) create mode 100644 cmd/setup/60.go create mode 100644 internal/serviceping/client.go create mode 100644 internal/serviceping/config.go create mode 100644 internal/serviceping/mock/mock_gen.go create mode 100644 internal/serviceping/mock/queries.mock.go create mode 100644 internal/serviceping/mock/queue.mock.go create mode 100644 internal/serviceping/mock/telemetry.mock.go create mode 100644 internal/serviceping/report.go create mode 100644 internal/serviceping/worker.go create mode 100644 internal/serviceping/worker_test.go create mode 100644 internal/v2/system/event.go diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index f1fc6a2414..f88616b821 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1203,6 +1203,37 @@ DefaultInstance: # If an audit log retention is set using an instance limit, it will overwrite the system default. AuditLogRetention: 0s # ZITADEL_AUDITLOGRETENTION +# The ServicePing are periodic reports of analytics data and the usage of ZITADEL. +# It is sent to a central endpoint to help us improve ZITADEL. +# It's enabled by default, but you can opt out either completely or by disabling specific telemetry data. +ServicePing: + # By setting Enabled to false, the service ping is disabled completely. + Enabled: true # ZITADEL_SERVICEPING_ENABLED + # The endpoint to which the reports are sent. The endpoint is used as a base path. Individual reports are sent to the endpoint with a specific path. + Endpoint: "https://zitadel.cloud/api/ping" # ZITADEL_SERVICEPING_ENDPOINT + # Interval at which the service ping is sent to the endpoint. + # The interval is in the format of a cron expression. + # By default, it is set to every day at midnight: + Interval: "0 0 * * *" # ZITADEL_SERVICEPING_INTERVAL + # Maximum number of attempts for each individual report to be sent. + # If one report fails, it will be retried up to this number of times. + # Other reports will still be handled in parallel and have their own retry count. + # This means if the base information only succeeded after 3 attempts, + # the resource count still has 5 attempts to be sent. + MaxAttempts: 5 # ZITADEL_SERVICEPING_MAXATTEMPTS + # The following features can be enabled or disabled individually. + # By default, all features are enabled. + # Note that if the service ping is enabled, base information about the system is always sent. + # This includes the version and the id, creation date and domains of all instances. + # If you disable a feature, it will not be sent in the service ping. + # Some features provide additional configuration options, if enabled. + Telemetry: + # ResourceCount is a periodic report of the number of resources in ZITADEL. + # This includes the number of users, organizations, projects, and other resources. + ResourceCount: + Enabled: true # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_ENABLED + BulkSize: 10000 # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_BULKSIZE + InternalAuthZ: # Configure the RolePermissionMappings by environment variable using JSON notation: # ZITADEL_INTERNALAUTHZ_ROLEPERMISSIONMAPPINGS='[{"role": "IAM_OWNER", "permissions": ["iam.write"]}, {"role": "ORG_OWNER", "permissions": ["org.write"]}]' diff --git a/cmd/setup/60.go b/cmd/setup/60.go new file mode 100644 index 0000000000..3f606c2212 --- /dev/null +++ b/cmd/setup/60.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/serviceping" + "github.com/zitadel/zitadel/internal/v2/system" +) + +type GenerateSystemID struct { + eventstore *eventstore.Eventstore +} + +func (mig *GenerateSystemID) Execute(ctx context.Context, _ eventstore.Event) error { + id, err := serviceping.GenerateSystemID() + if err != nil { + return err + } + _, err = mig.eventstore.Push(ctx, system.NewIDGeneratedEvent(ctx, id)) + return err +} + +func (mig *GenerateSystemID) String() string { + return "60_generate_system_id" +} diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 7385cc7652..bac73b0ae5 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -156,6 +156,7 @@ type Steps struct { s57CreateResourceCounts *CreateResourceCounts s58ReplaceLoginNames3View *ReplaceLoginNames3View s59SetupWebkeys *SetupWebkeys + s60GenerateSystemID *GenerateSystemID } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index dd23c320c7..15236a73e9 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -217,6 +217,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s56IDPTemplate6SAMLFederatedLogout = &IDPTemplate6SAMLFederatedLogout{dbClient: dbClient} steps.s57CreateResourceCounts = &CreateResourceCounts{dbClient: dbClient} steps.s58ReplaceLoginNames3View = &ReplaceLoginNames3View{dbClient: dbClient} + steps.s60GenerateSystemID = &GenerateSystemID{eventstore: eventstoreClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -264,6 +265,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s56IDPTemplate6SAMLFederatedLogout, steps.s57CreateResourceCounts, steps.s58ReplaceLoginNames3View, + steps.s60GenerateSystemID, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/cmd/start/config.go b/cmd/start/config.go index 78b6f0afe0..c680bf7c05 100644 --- a/cmd/start/config.go +++ b/cmd/start/config.go @@ -32,6 +32,7 @@ import ( "github.com/zitadel/zitadel/internal/logstore" "github.com/zitadel/zitadel/internal/notification/handlers" "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/serviceping" static_config "github.com/zitadel/zitadel/internal/static/config" metrics "github.com/zitadel/zitadel/internal/telemetry/metrics/config" profiler "github.com/zitadel/zitadel/internal/telemetry/profiler/config" @@ -81,6 +82,7 @@ type Config struct { LogStore *logstore.Configs Quotas *QuotasConfig Telemetry *handlers.TelemetryPusherConfig + ServicePing *serviceping.Config } type QuotasConfig struct { diff --git a/cmd/start/start.go b/cmd/start/start.go index dbd6289041..06f3554a58 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -99,6 +99,7 @@ import ( "github.com/zitadel/zitadel/internal/notification" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/queue" + "github.com/zitadel/zitadel/internal/serviceping" "github.com/zitadel/zitadel/internal/static" es_v4 "github.com/zitadel/zitadel/internal/v2/eventstore" es_v4_pg "github.com/zitadel/zitadel/internal/v2/eventstore/postgres" @@ -317,10 +318,20 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server ) execution.Start(ctx) + // the service ping and it's workers need to be registered before starting the queue + if err := serviceping.Register(ctx, q, queries, eventstoreClient, config.ServicePing); err != nil { + return err + } + if err = q.Start(ctx); err != nil { return err } + // the scheduler / periodic jobs need to be started after the queue already runs + if err = serviceping.Start(config.ServicePing, q); err != nil { + return err + } + router := mux.NewRouter() tlsConfig, err := config.TLS.Config() if err != nil { diff --git a/go.mod b/go.mod index 9d02050b48..f0ab6246d6 100644 --- a/go.mod +++ b/go.mod @@ -67,6 +67,7 @@ require ( github.com/riverqueue/river/riverdriver v0.22.0 github.com/riverqueue/river/rivertype v0.22.0 github.com/riverqueue/rivercontrib/otelriver v0.5.0 + github.com/robfig/cron/v3 v3.0.1 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/shopspring/decimal v1.3.1 diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 22df8c2b5c..44e291bf4d 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -9,6 +9,7 @@ import ( "github.com/riverqueue/river/riverdriver/riverpgxv5" "github.com/riverqueue/river/rivertype" "github.com/riverqueue/rivercontrib/otelriver" + "github.com/robfig/cron/v3" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" @@ -75,6 +76,26 @@ func (q *Queue) AddWorkers(w ...Worker) { } } +func (q *Queue) AddPeriodicJob(schedule cron.Schedule, jobArgs river.JobArgs, opts ...InsertOpt) (handle rivertype.PeriodicJobHandle) { + if q == nil { + logging.Info("skip adding periodic job because queue is not set") + return + } + options := new(river.InsertOpts) + for _, opt := range opts { + opt(options) + } + return q.client.PeriodicJobs().Add( + river.NewPeriodicJob( + schedule, + func() (river.JobArgs, *river.InsertOpts) { + return jobArgs, options + }, + nil, + ), + ) +} + type InsertOpt func(*river.InsertOpts) func WithMaxAttempts(maxAttempts uint8) InsertOpt { diff --git a/internal/serviceping/client.go b/internal/serviceping/client.go new file mode 100644 index 0000000000..87711aada6 --- /dev/null +++ b/internal/serviceping/client.go @@ -0,0 +1,153 @@ +package serviceping + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net/http" + + "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" +) + +const ( + pathBaseInformation = "/instances" + pathResourceCounts = "/resource_counts" +) + +type Client struct { + httpClient *http.Client + endpoint string +} + +func (c Client) ReportBaseInformation(ctx context.Context, in *analytics.ReportBaseInformationRequest, opts ...grpc.CallOption) (*analytics.ReportBaseInformationResponse, error) { + reportResponse := new(analytics.ReportBaseInformationResponse) + err := c.callTelemetryService(ctx, pathBaseInformation, in, reportResponse) + if err != nil { + return nil, err + } + return reportResponse, nil +} + +func (c Client) ReportResourceCounts(ctx context.Context, in *analytics.ReportResourceCountsRequest, opts ...grpc.CallOption) (*analytics.ReportResourceCountsResponse, error) { + reportResponse := new(analytics.ReportResourceCountsResponse) + err := c.callTelemetryService(ctx, pathResourceCounts, in, reportResponse) + if err != nil { + return nil, err + } + return reportResponse, nil +} + +func (c Client) callTelemetryService(ctx context.Context, path string, in proto.Message, out proto.Message) error { + requestBody, err := protojson.Marshal(in) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+path, bytes.NewReader(requestBody)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return &TelemetryError{ + StatusCode: resp.StatusCode, + Body: body, + } + } + + return protojson.UnmarshalOptions{ + AllowPartial: true, + DiscardUnknown: true, + }.Unmarshal(body, out) +} + +func NewClient(config *Config) Client { + return Client{ + httpClient: http.DefaultClient, + endpoint: config.Endpoint, + } +} + +func GenerateSystemID() (string, error) { + randBytes := make([]byte, 64) + if _, err := rand.Read(randBytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(randBytes), nil +} + +func instanceInformationToPb(instances *query.Instances) []*analytics.InstanceInformation { + instanceInformation := make([]*analytics.InstanceInformation, len(instances.Instances)) + for i, instance := range instances.Instances { + domains := instanceDomainToPb(instance) + instanceInformation[i] = &analytics.InstanceInformation{ + Id: instance.ID, + Domains: domains, + CreatedAt: timestamppb.New(instance.CreationDate), + } + } + return instanceInformation +} + +func instanceDomainToPb(instance *query.Instance) []string { + domains := make([]string, len(instance.Domains)) + for i, domain := range instance.Domains { + domains[i] = domain.Domain + } + return domains +} + +func resourceCountsToPb(counts []query.ResourceCount) []*analytics.ResourceCount { + resourceCounts := make([]*analytics.ResourceCount, len(counts)) + for i, count := range counts { + resourceCounts[i] = &analytics.ResourceCount{ + InstanceId: count.InstanceID, + ParentType: countParentTypeToPb(count.ParentType), + ParentId: count.ParentID, + ResourceName: count.Resource, + TableName: count.TableName, + UpdatedAt: timestamppb.New(count.UpdatedAt), + Amount: uint32(count.Amount), + } + } + return resourceCounts +} + +func countParentTypeToPb(parentType domain.CountParentType) analytics.CountParentType { + switch parentType { + case domain.CountParentTypeInstance: + return analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE + case domain.CountParentTypeOrganization: + return analytics.CountParentType_COUNT_PARENT_TYPE_ORGANIZATION + default: + return analytics.CountParentType_COUNT_PARENT_TYPE_UNSPECIFIED + } +} + +type TelemetryError struct { + StatusCode int + Body []byte +} + +func (e *TelemetryError) Error() string { + return fmt.Sprintf("telemetry error %d: %s", e.StatusCode, e.Body) +} diff --git a/internal/serviceping/config.go b/internal/serviceping/config.go new file mode 100644 index 0000000000..13f2311324 --- /dev/null +++ b/internal/serviceping/config.go @@ -0,0 +1,18 @@ +package serviceping + +type Config struct { + Enabled bool + Endpoint string + Interval string + MaxAttempts uint8 + Telemetry TelemetryConfig +} + +type TelemetryConfig struct { + ResourceCount ResourceCount +} + +type ResourceCount struct { + Enabled bool + BulkSize int +} diff --git a/internal/serviceping/mock/mock_gen.go b/internal/serviceping/mock/mock_gen.go new file mode 100644 index 0000000000..6b4d2defbe --- /dev/null +++ b/internal/serviceping/mock/mock_gen.go @@ -0,0 +1,5 @@ +package mock + +//go:generate mockgen -package mock -destination queue.mock.go github.com/zitadel/zitadel/internal/serviceping Queue +//go:generate mockgen -package mock -destination queries.mock.go github.com/zitadel/zitadel/internal/serviceping Queries +//go:generate mockgen -package mock -destination telemetry.mock.go github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta TelemetryServiceClient diff --git a/internal/serviceping/mock/queries.mock.go b/internal/serviceping/mock/queries.mock.go new file mode 100644 index 0000000000..593c4d5ff7 --- /dev/null +++ b/internal/serviceping/mock/queries.mock.go @@ -0,0 +1,72 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/serviceping (interfaces: Queries) +// +// Generated by this command: +// +// mockgen -package mock -destination queries.mock.go github.com/zitadel/zitadel/internal/serviceping Queries +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + query "github.com/zitadel/zitadel/internal/query" + gomock "go.uber.org/mock/gomock" +) + +// MockQueries is a mock of Queries interface. +type MockQueries struct { + ctrl *gomock.Controller + recorder *MockQueriesMockRecorder + isgomock struct{} +} + +// MockQueriesMockRecorder is the mock recorder for MockQueries. +type MockQueriesMockRecorder struct { + mock *MockQueries +} + +// NewMockQueries creates a new mock instance. +func NewMockQueries(ctrl *gomock.Controller) *MockQueries { + mock := &MockQueries{ctrl: ctrl} + mock.recorder = &MockQueriesMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockQueries) EXPECT() *MockQueriesMockRecorder { + return m.recorder +} + +// ListResourceCounts mocks base method. +func (m *MockQueries) ListResourceCounts(ctx context.Context, lastID, size int) ([]query.ResourceCount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListResourceCounts", ctx, lastID, size) + ret0, _ := ret[0].([]query.ResourceCount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListResourceCounts indicates an expected call of ListResourceCounts. +func (mr *MockQueriesMockRecorder) ListResourceCounts(ctx, lastID, size any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListResourceCounts", reflect.TypeOf((*MockQueries)(nil).ListResourceCounts), ctx, lastID, size) +} + +// SearchInstances mocks base method. +func (m *MockQueries) SearchInstances(ctx context.Context, queries *query.InstanceSearchQueries) (*query.Instances, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchInstances", ctx, queries) + ret0, _ := ret[0].(*query.Instances) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchInstances indicates an expected call of SearchInstances. +func (mr *MockQueriesMockRecorder) SearchInstances(ctx, queries any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstances", reflect.TypeOf((*MockQueries)(nil).SearchInstances), ctx, queries) +} diff --git a/internal/serviceping/mock/queue.mock.go b/internal/serviceping/mock/queue.mock.go new file mode 100644 index 0000000000..e984352a8c --- /dev/null +++ b/internal/serviceping/mock/queue.mock.go @@ -0,0 +1,62 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/internal/serviceping (interfaces: Queue) +// +// Generated by this command: +// +// mockgen -package mock -destination queue.mock.go github.com/zitadel/zitadel/internal/serviceping Queue +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + river "github.com/riverqueue/river" + queue "github.com/zitadel/zitadel/internal/queue" + gomock "go.uber.org/mock/gomock" +) + +// MockQueue is a mock of Queue interface. +type MockQueue struct { + ctrl *gomock.Controller + recorder *MockQueueMockRecorder + isgomock struct{} +} + +// MockQueueMockRecorder is the mock recorder for MockQueue. +type MockQueueMockRecorder struct { + mock *MockQueue +} + +// NewMockQueue creates a new mock instance. +func NewMockQueue(ctrl *gomock.Controller) *MockQueue { + mock := &MockQueue{ctrl: ctrl} + mock.recorder = &MockQueueMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockQueue) EXPECT() *MockQueueMockRecorder { + return m.recorder +} + +// Insert mocks base method. +func (m *MockQueue) Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error { + m.ctrl.T.Helper() + varargs := []any{ctx, args} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Insert", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Insert indicates an expected call of Insert. +func (mr *MockQueueMockRecorder) Insert(ctx, args any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, args}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockQueue)(nil).Insert), varargs...) +} diff --git a/internal/serviceping/mock/telemetry.mock.go b/internal/serviceping/mock/telemetry.mock.go new file mode 100644 index 0000000000..536bf34671 --- /dev/null +++ b/internal/serviceping/mock/telemetry.mock.go @@ -0,0 +1,83 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta (interfaces: TelemetryServiceClient) +// +// Generated by this command: +// +// mockgen -package mock -destination telemetry.mock.go github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta TelemetryServiceClient +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" + gomock "go.uber.org/mock/gomock" + grpc "google.golang.org/grpc" +) + +// MockTelemetryServiceClient is a mock of TelemetryServiceClient interface. +type MockTelemetryServiceClient struct { + ctrl *gomock.Controller + recorder *MockTelemetryServiceClientMockRecorder + isgomock struct{} +} + +// MockTelemetryServiceClientMockRecorder is the mock recorder for MockTelemetryServiceClient. +type MockTelemetryServiceClientMockRecorder struct { + mock *MockTelemetryServiceClient +} + +// NewMockTelemetryServiceClient creates a new mock instance. +func NewMockTelemetryServiceClient(ctrl *gomock.Controller) *MockTelemetryServiceClient { + mock := &MockTelemetryServiceClient{ctrl: ctrl} + mock.recorder = &MockTelemetryServiceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockTelemetryServiceClient) EXPECT() *MockTelemetryServiceClientMockRecorder { + return m.recorder +} + +// ReportBaseInformation mocks base method. +func (m *MockTelemetryServiceClient) ReportBaseInformation(ctx context.Context, in *analytics.ReportBaseInformationRequest, opts ...grpc.CallOption) (*analytics.ReportBaseInformationResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReportBaseInformation", varargs...) + ret0, _ := ret[0].(*analytics.ReportBaseInformationResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReportBaseInformation indicates an expected call of ReportBaseInformation. +func (mr *MockTelemetryServiceClientMockRecorder) ReportBaseInformation(ctx, in any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportBaseInformation", reflect.TypeOf((*MockTelemetryServiceClient)(nil).ReportBaseInformation), varargs...) +} + +// ReportResourceCounts mocks base method. +func (m *MockTelemetryServiceClient) ReportResourceCounts(ctx context.Context, in *analytics.ReportResourceCountsRequest, opts ...grpc.CallOption) (*analytics.ReportResourceCountsResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, in} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReportResourceCounts", varargs...) + ret0, _ := ret[0].(*analytics.ReportResourceCountsResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReportResourceCounts indicates an expected call of ReportResourceCounts. +func (mr *MockTelemetryServiceClientMockRecorder) ReportResourceCounts(ctx, in any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, in}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReportResourceCounts", reflect.TypeOf((*MockTelemetryServiceClient)(nil).ReportResourceCounts), varargs...) +} diff --git a/internal/serviceping/report.go b/internal/serviceping/report.go new file mode 100644 index 0000000000..d31f6a8f74 --- /dev/null +++ b/internal/serviceping/report.go @@ -0,0 +1,17 @@ +package serviceping + +type ReportType uint + +const ( + ReportTypeBaseInformation ReportType = iota + ReportTypeResourceCounts +) + +type ServicePingReport struct { + ReportID string + ReportType ReportType +} + +func (r *ServicePingReport) Kind() string { + return "service_ping_report" +} diff --git a/internal/serviceping/worker.go b/internal/serviceping/worker.go new file mode 100644 index 0000000000..0156373170 --- /dev/null +++ b/internal/serviceping/worker.go @@ -0,0 +1,252 @@ +package serviceping + +import ( + "context" + "errors" + "net/http" + + "github.com/muhlemmer/gu" + "github.com/riverqueue/river" + "github.com/robfig/cron/v3" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/cmd/build" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/queue" + "github.com/zitadel/zitadel/internal/v2/system" + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" +) + +const ( + QueueName = "service_ping_report" +) + +var ( + ErrInvalidReportType = errors.New("invalid report type") + + _ river.Worker[*ServicePingReport] = (*Worker)(nil) +) + +type Worker struct { + river.WorkerDefaults[*ServicePingReport] + + reportClient analytics.TelemetryServiceClient + db Queries + queue Queue + + config *Config + systemID string + version string +} + +type Queries interface { + SearchInstances(ctx context.Context, queries *query.InstanceSearchQueries) (*query.Instances, error) + ListResourceCounts(ctx context.Context, lastID int, size int) ([]query.ResourceCount, error) +} + +type Queue interface { + Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error +} + +// Register implements the [queue.Worker] interface. +func (w *Worker) Register(workers *river.Workers, queues map[string]river.QueueConfig) { + river.AddWorker[*ServicePingReport](workers, w) + queues[QueueName] = river.QueueConfig{ + MaxWorkers: 1, // for now, we only use a single worker to prevent too much side effects on other queues + } +} + +// Work implements the [river.Worker] interface. +func (w *Worker) Work(ctx context.Context, job *river.Job[*ServicePingReport]) (err error) { + defer func() { + err = w.handleClientError(err) + }() + switch job.Args.ReportType { + case ReportTypeBaseInformation: + reportID, err := w.reportBaseInformation(ctx) + if err != nil { + return err + } + return w.createReportJobs(ctx, reportID) + case ReportTypeResourceCounts: + return w.reportResourceCounts(ctx, job.Args.ReportID) + default: + logging.WithFields("reportType", job.Args.ReportType, "reportID", job.Args.ReportID). + Error("unknown job type") + return river.JobCancel(ErrInvalidReportType) + } +} + +func (w *Worker) reportBaseInformation(ctx context.Context) (string, error) { + instances, err := w.db.SearchInstances(ctx, &query.InstanceSearchQueries{}) + if err != nil { + return "", err + } + instanceInformation := instanceInformationToPb(instances) + resp, err := w.reportClient.ReportBaseInformation(ctx, &analytics.ReportBaseInformationRequest{ + SystemId: w.systemID, + Version: w.version, + Instances: instanceInformation, + }) + if err != nil { + return "", err + } + return resp.GetReportId(), nil +} + +func (w *Worker) reportResourceCounts(ctx context.Context, reportID string) error { + lastID := 0 + // iterate over the resource counts until there are no more counts to report + // or the context gets cancelled + for { + select { + case <-ctx.Done(): + return nil + default: + counts, err := w.db.ListResourceCounts(ctx, lastID, w.config.Telemetry.ResourceCount.BulkSize) + if err != nil { + return err + } + // if there are no counts, we can stop the loop + if len(counts) == 0 { + return nil + } + request := &analytics.ReportResourceCountsRequest{ + SystemId: w.systemID, + ResourceCounts: resourceCountsToPb(counts), + } + if reportID != "" { + request.ReportId = gu.Ptr(reportID) + } + resp, err := w.reportClient.ReportResourceCounts(ctx, request) + if err != nil { + return err + } + // in case the resource counts returned by the database are less than the bulk size, + // we can assume that we have reached the end of the resource counts and can stop the loop + if len(counts) < w.config.Telemetry.ResourceCount.BulkSize { + return nil + } + // update the lastID for the next iteration + lastID = counts[len(counts)-1].ID + // In case we get a report ID back from the server (it could be the first call of the report), + // we update it to use it for the next batch. + if resp.GetReportId() != "" && resp.GetReportId() != reportID { + reportID = resp.GetReportId() + } + } + } +} + +func (w *Worker) handleClientError(err error) error { + telemetryError := new(TelemetryError) + if !errors.As(err, &telemetryError) { + // If the error is not a TelemetryError, we can assume that it is a transient error + // and can be retried by the queue. + return err + } + switch telemetryError.StatusCode { + case http.StatusBadRequest, + http.StatusNotFound, + http.StatusNotImplemented, + http.StatusConflict, + http.StatusPreconditionFailed: + // In case of these errors, we can assume that a retry does not make sense, + // so we can cancel the job. + return river.JobCancel(err) + default: + // As of now we assume that all other errors are transient and can be retried. + // So we just return the error, which will be handled by the queue as a failed attempt. + return err + } +} + +func (w *Worker) createReportJobs(ctx context.Context, reportID string) error { + errs := make([]error, 0) + if w.config.Telemetry.ResourceCount.Enabled { + err := w.addReportJob(ctx, reportID, ReportTypeResourceCounts) + if err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func (w *Worker) addReportJob(ctx context.Context, reportID string, reportType ReportType) error { + job := &ServicePingReport{ + ReportID: reportID, + ReportType: reportType, + } + return w.queue.Insert(ctx, job, + queue.WithQueueName(QueueName), + queue.WithMaxAttempts(w.config.MaxAttempts), + ) +} + +type systemIDReducer struct { + id string +} + +func (s *systemIDReducer) Reduce() error { + return nil +} + +func (s *systemIDReducer) AppendEvents(events ...eventstore.Event) { + for _, event := range events { + if idEvent, ok := event.(*system.IDGeneratedEvent); ok { + s.id = idEvent.ID + } + } +} + +func (s *systemIDReducer) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + AddQuery(). + AggregateTypes(system.AggregateType). + EventTypes(system.IDGeneratedType). + Builder() +} + +func Register( + ctx context.Context, + q *queue.Queue, + queries *query.Queries, + eventstoreClient *eventstore.Eventstore, + config *Config, +) error { + if !config.Enabled { + return nil + } + systemID := new(systemIDReducer) + err := eventstoreClient.FilterToQueryReducer(ctx, systemID) + if err != nil { + return err + } + q.AddWorkers(&Worker{ + reportClient: NewClient(config), + db: queries, + queue: q, + config: config, + systemID: systemID.id, + version: build.Version(), + }) + return nil +} + +func Start(config *Config, q *queue.Queue) error { + if !config.Enabled { + return nil + } + schedule, err := cron.ParseStandard(config.Interval) + if err != nil { + return err + } + q.AddPeriodicJob( + schedule, + &ServicePingReport{}, + queue.WithQueueName(QueueName), + queue.WithMaxAttempts(config.MaxAttempts), + ) + return nil +} diff --git a/internal/serviceping/worker_test.go b/internal/serviceping/worker_test.go new file mode 100644 index 0000000000..373eee9b6e --- /dev/null +++ b/internal/serviceping/worker_test.go @@ -0,0 +1,1052 @@ +package serviceping + +import ( + "context" + "fmt" + "net/http" + "reflect" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/riverqueue/river" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/queue" + "github.com/zitadel/zitadel/internal/serviceping/mock" + "github.com/zitadel/zitadel/internal/zerrors" + analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" +) + +var ( + testNow = time.Now() + errInsert = fmt.Errorf("insert error") +) + +func TestWorker_reportBaseInformation(t *testing.T) { + type fields struct { + reportClient func(*testing.T) analytics.TelemetryServiceClient + db func(*testing.T) Queries + systemID string + version string + } + type args struct { + ctx context.Context + } + type want struct { + reportID string + err error + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "database error, error", + fields: fields{ + db: func(t *testing.T) Queries { + ctrl := gomock.NewController(t) + queries := mock.NewMockQueries(ctrl) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + nil, zerrors.ThrowInternal(nil, "id", "db error"), + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + }, + want: want{ + reportID: "", + err: zerrors.ThrowInternal(nil, "id", "db error"), + }, + }, + { + name: "telemetry client error, error", + fields: fields{ + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain", "domain2"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + nil, status.Error(codes.Internal, "error"), + ) + return client + }, + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + { + Domain: "domain2", + }, + }, + }, + }, + }, + nil, + ) + return queries + }, + systemID: "system-id", + version: "version", + }, + want: want{ + reportID: "", + err: status.Error(codes.Internal, "error"), + }, + }, + { + name: "report ok, reportID returned", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + { + Domain: "domain2", + }, + }, + }, + }, + }, + nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain", "domain2"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ReportId: "report-id"}, nil, + ) + return client + }, + systemID: "system-id", + version: "version", + }, + want: want{ + reportID: "report-id", + err: nil, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{ + reportClient: tt.fields.reportClient(t), + db: tt.fields.db(t), + systemID: tt.fields.systemID, + version: tt.fields.version, + } + got, err := w.reportBaseInformation(tt.args.ctx) + assert.Equal(t, tt.want.reportID, got) + assert.ErrorIs(t, err, tt.want.err) + }) + } +} + +func TestWorker_reportResourceCounts(t *testing.T) { + type fields struct { + reportClient func(*testing.T) analytics.TelemetryServiceClient + db func(*testing.T) Queries + config *Config + systemID string + } + type args struct { + ctx context.Context + reportID string + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "database error, error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 1).Return( + nil, zerrors.ThrowInternal(nil, "id", "db error"), + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 1, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: zerrors.ThrowInternal(nil, "id", "db error"), + }, + { + name: "no resource counts, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 1).Return( + []query.ResourceCount{}, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 1, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: nil, + }, + { + name: "telemetry client error, error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: nil, + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + nil, status.Error(codes.Internal, "error"), + ) + return client + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: status.Error(codes.Internal, "error"), + }, + { + name: "report ok, no additional counts, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: nil, + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: nil, + }, + { + name: "report ok, additional counts, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + { + ID: 2, + InstanceID: "instance-id2", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id2", + Resource: "resource", + UpdatedAt: testNow, + Amount: 5, + }, + }, nil, + ) + queries.EXPECT().ListResourceCounts(gomock.Any(), 2, 2).Return( + []query.ResourceCount{ + { + ID: 3, + InstanceID: "instance-id3", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id3", + Resource: "resource", + UpdatedAt: testNow, + Amount: 20, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: nil, + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + { + InstanceId: "instance-id2", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id2", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 5, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id3", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id3", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 20, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + reportID: "", + }, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{ + reportClient: tt.fields.reportClient(t), + db: tt.fields.db(t), + config: tt.fields.config, + systemID: tt.fields.systemID, + } + err := w.reportResourceCounts(tt.args.ctx, tt.args.reportID) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} + +func TestWorker_Work(t *testing.T) { + type fields struct { + WorkerDefaults river.WorkerDefaults[*ServicePingReport] + reportClient func(*testing.T) analytics.TelemetryServiceClient + db func(*testing.T) Queries + queue func(*testing.T) Queue + config *Config + systemID string + version string + } + type args struct { + ctx context.Context + job *river.Job[*ServicePingReport] + } + tests := []struct { + name string + fields fields + args args + wantErr error + }{ + { + name: "unknown report type, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + return mock.NewMockQueries(gomock.NewController(t)) + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: 100000, + }, + }, + }, + wantErr: river.JobCancel(ErrInvalidReportType), + }, + { + name: "report base information, database error, retry job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + nil, zerrors.ThrowInternal(nil, "id", "db error"), + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + return mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + wantErr: zerrors.ThrowInternal(nil, "id", "db error"), + }, + { + name: "report base information, config error, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + nil, &TelemetryError{StatusCode: http.StatusNotFound, Body: []byte("endpoint not found")}, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + wantErr: river.JobCancel(&TelemetryError{StatusCode: http.StatusNotFound, Body: []byte("endpoint not found")}), + }, + { + name: "report base information, no reports enabled, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + Enabled: false, + }, + }, + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + }, + { + name: "report base information, job creation error, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + q := mock.NewMockQueue(gomock.NewController(t)) + q.EXPECT().Insert(gomock.Any(), + &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithQueueName(QueueName))), + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithMaxAttempts(5)))). // TODO: better solution + Return(errInsert) + return q + }, + config: &Config{ + MaxAttempts: 5, + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + Enabled: true, + }, + }, + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + wantErr: errInsert, + }, + { + name: "report base information, success, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().SearchInstances(gomock.Any(), &query.InstanceSearchQueries{}).Return( + &query.Instances{ + Instances: []*query.Instance{ + { + ID: "id", + CreationDate: testNow, + Domains: []*query.InstanceDomain{ + { + Domain: "domain", + }, + }, + }, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportBaseInformation(gomock.Any(), &analytics.ReportBaseInformationRequest{ + SystemId: "system-id", + Version: "version", + Instances: []*analytics.InstanceInformation{ + { + Id: "id", + Domains: []string{"domain"}, + CreatedAt: timestamppb.New(testNow), + }, + }, + }).Return( + &analytics.ReportBaseInformationResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + q := mock.NewMockQueue(gomock.NewController(t)) + q.EXPECT().Insert(gomock.Any(), + &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithQueueName(QueueName))), + gomock.AssignableToTypeOf(reflect.TypeOf(queue.WithMaxAttempts(5)))). + Return(nil) + return q + }, + config: &Config{ + MaxAttempts: 5, + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + Enabled: true, + }, + }, + }, + systemID: "system-id", + version: "version", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeBaseInformation, + }, + }, + }, + }, + { + name: "report resource counts, service unavailable, retry job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + nil, status.Error(codes.Unavailable, "service unavailable"), + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportType: ReportTypeResourceCounts, + ReportID: "report-id", + }, + }, + }, + wantErr: status.Error(codes.Unavailable, "service unavailable"), + }, + { + name: "report resource counts, precondition error, cancel job", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + nil, &TelemetryError{StatusCode: http.StatusPreconditionFailed, Body: []byte("report too old")}, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + }, + }, + wantErr: river.JobCancel(&TelemetryError{StatusCode: http.StatusPreconditionFailed, Body: []byte("report too old")}), + }, + { + name: "report resource counts, success, no error", + fields: fields{ + db: func(t *testing.T) Queries { + queries := mock.NewMockQueries(gomock.NewController(t)) + queries.EXPECT().ListResourceCounts(gomock.Any(), 0, 2).Return( + []query.ResourceCount{ + { + ID: 1, + InstanceID: "instance-id", + TableName: "table_name", + ParentType: domain.CountParentTypeInstance, + ParentID: "instance-id", + Resource: "resource", + UpdatedAt: testNow, + Amount: 10, + }, + }, nil, + ) + return queries + }, + reportClient: func(t *testing.T) analytics.TelemetryServiceClient { + client := mock.NewMockTelemetryServiceClient(gomock.NewController(t)) + client.EXPECT().ReportResourceCounts(gomock.Any(), &analytics.ReportResourceCountsRequest{ + SystemId: "system-id", + ReportId: gu.Ptr("report-id"), + ResourceCounts: []*analytics.ResourceCount{ + { + InstanceId: "instance-id", + TableName: "table_name", + ParentType: analytics.CountParentType_COUNT_PARENT_TYPE_INSTANCE, + ParentId: "instance-id", + ResourceName: "resource", + UpdatedAt: timestamppb.New(testNow), + Amount: 10, + }, + }, + }).Return( + &analytics.ReportResourceCountsResponse{ + ReportId: "report-id", + }, nil, + ) + return client + }, + queue: func(t *testing.T) Queue { + return mock.NewMockQueue(gomock.NewController(t)) + }, + config: &Config{ + Telemetry: TelemetryConfig{ + ResourceCount: ResourceCount{ + BulkSize: 2, + }, + }, + }, + systemID: "system-id", + }, + args: args{ + ctx: context.Background(), + job: &river.Job[*ServicePingReport]{ + Args: &ServicePingReport{ + ReportID: "report-id", + ReportType: ReportTypeResourceCounts, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &Worker{ + WorkerDefaults: river.WorkerDefaults[*ServicePingReport]{}, + reportClient: tt.fields.reportClient(t), + db: tt.fields.db(t), + queue: tt.fields.queue(t), + config: tt.fields.config, + systemID: tt.fields.systemID, + version: tt.fields.version, + } + err := w.Work(tt.args.ctx, tt.args.job) + assert.ErrorIs(t, err, tt.wantErr) + }) + } +} diff --git a/internal/v2/system/event.go b/internal/v2/system/event.go new file mode 100644 index 0000000000..313c0fb293 --- /dev/null +++ b/internal/v2/system/event.go @@ -0,0 +1,44 @@ +package system + +import ( + "context" + + "github.com/zitadel/zitadel/internal/eventstore" +) + +func init() { + eventstore.RegisterFilterEventMapper(AggregateType, IDGeneratedType, eventstore.GenericEventMapper[IDGeneratedEvent]) +} + +const IDGeneratedType = AggregateType + ".id.generated" + +type IDGeneratedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id"` +} + +func (e *IDGeneratedEvent) SetBaseEvent(b *eventstore.BaseEvent) { + e.BaseEvent = *b +} + +func (e *IDGeneratedEvent) Payload() interface{} { + return e +} + +func (e *IDGeneratedEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func NewIDGeneratedEvent( + ctx context.Context, + id string, +) *IDGeneratedEvent { + return &IDGeneratedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + eventstore.NewAggregate(ctx, AggregateOwner, AggregateType, "v1"), + IDGeneratedType), + ID: id, + } +} From 8c39779533d5c8ee6d6a34d6d70686ac7853d8ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:32:16 +0000 Subject: [PATCH 118/123] chore(deps): bump github.com/go-chi/chi/v5 from 5.2.1 to 5.2.2 in /login/apps/login-test-acceptance/idp/oidc in the go_modules group across 1 directory (#10152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the go_modules group with 1 update in the /login/apps/login-test-acceptance/idp/oidc directory: [github.com/go-chi/chi/v5](https://github.com/go-chi/chi). Updates `github.com/go-chi/chi/v5` from 5.2.1 to 5.2.2
Release notes

Sourced from github.com/go-chi/chi/v5's releases.

v5.2.2

What's Changed

Security fix

  • Fixes GHSA-vrw8-fxc6-2r93 - "Host Header Injection Leads to Open Redirect in RedirectSlashes" commit
    • a lower-severity Open Redirect that can't be exploited in browser or email client, as it requires manipulation of a Host header
    • reported by Anuraag Baishya, @​anuraagbaishya. Thank you!

New Contributors

Full Changelog: https://github.com/go-chi/chi/compare/v5.2.1...v5.2.2

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/go-chi/chi/v5&package-manager=go_modules&previous-version=5.2.1&new-version=5.2.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore major version` will close this group update PR and stop Dependabot creating any more for the specific dependency's major version (unless you unignore this specific dependency's major version or upgrade to it yourself) - `@dependabot ignore minor version` will close this group update PR and stop Dependabot creating any more for the specific dependency's minor version (unless you unignore this specific dependency's minor version or upgrade to it yourself) - `@dependabot ignore ` will close this group update PR and stop Dependabot creating any more for the specific dependency (unless you unignore this specific dependency or upgrade to it yourself) - `@dependabot unignore ` will remove all of the ignore conditions of the specified dependency - `@dependabot unignore ` will remove the ignore condition of the specified dependency and ignore conditions You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/zitadel/zitadel/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- login/apps/login-test-acceptance/idp/oidc/go.mod | 2 +- login/apps/login-test-acceptance/idp/oidc/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/login/apps/login-test-acceptance/idp/oidc/go.mod b/login/apps/login-test-acceptance/idp/oidc/go.mod index 84dae766c8..bc43390218 100644 --- a/login/apps/login-test-acceptance/idp/oidc/go.mod +++ b/login/apps/login-test-acceptance/idp/oidc/go.mod @@ -6,7 +6,7 @@ require github.com/zitadel/oidc/v3 v3.37.0 require ( github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect - github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/go-chi/chi/v5 v5.2.2 // indirect github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect diff --git a/login/apps/login-test-acceptance/idp/oidc/go.sum b/login/apps/login-test-acceptance/idp/oidc/go.sum index 42d80d8683..23fd2b3384 100644 --- a/login/apps/login-test-acceptance/idp/oidc/go.sum +++ b/login/apps/login-test-acceptance/idp/oidc/go.sum @@ -3,8 +3,8 @@ github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTS github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= From 47f0486ee8ec868b742a3ba05721413d95bae21c Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 3 Jul 2025 10:07:34 +0200 Subject: [PATCH 119/123] fix(login): email or phone query, session context from loginname (#10158) This PR fixes an issue where the orQuery for phone and email was not correctly set. --- .../login/src/app/(login)/loginname/page.tsx | 2 +- login/apps/login/src/lib/server/loginname.ts | 58 +++++++++++-------- login/apps/login/src/lib/session.ts | 1 - login/apps/login/src/lib/zitadel.ts | 10 ++-- 4 files changed, 41 insertions(+), 30 deletions(-) diff --git a/login/apps/login/src/app/(login)/loginname/page.tsx b/login/apps/login/src/app/(login)/loginname/page.tsx index 6d8f209572..f15f440930 100644 --- a/login/apps/login/src/app/(login)/loginname/page.tsx +++ b/login/apps/login/src/app/(login)/loginname/page.tsx @@ -61,7 +61,7 @@ export default async function Page(props: { return (
-

+

diff --git a/login/apps/login/src/lib/server/loginname.ts b/login/apps/login/src/lib/server/loginname.ts index 68cb345c06..dee740bf4f 100644 --- a/login/apps/login/src/lib/server/loginname.ts +++ b/login/apps/login/src/lib/server/loginname.ts @@ -291,23 +291,25 @@ export async function sendLoginname(command: SendLoginnameCommand) { }; } - const paramsPassword: any = { + const paramsPassword = new URLSearchParams({ loginName: session.factors?.user?.loginName, - }; + }); // TODO: does this have to be checked in loginSettings.allowDomainDiscovery if (command.organization || session.factors?.user?.organizationId) { - paramsPassword.organization = - command.organization ?? session.factors?.user?.organizationId; + paramsPassword.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } if (command.requestId) { - paramsPassword.requestId = command.requestId; + paramsPassword.append("requestId", command.requestId); } return { - redirect: "/password?" + new URLSearchParams(paramsPassword), + redirect: "/password?" + paramsPassword, }; case AuthenticationMethodType.PASSKEY: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY @@ -318,36 +320,42 @@ export async function sendLoginname(command: SendLoginnameCommand) { }; } - const paramsPasskey: any = { loginName: command.loginName }; + const paramsPasskey = new URLSearchParams({ + loginName: session.factors?.user?.loginName, + }); if (command.requestId) { - paramsPasskey.requestId = command.requestId; + paramsPasskey.append("requestId", command.requestId); } if (command.organization || session.factors?.user?.organizationId) { - paramsPasskey.organization = - command.organization ?? session.factors?.user?.organizationId; + paramsPasskey.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } - return { redirect: "/passkey?" + new URLSearchParams(paramsPasskey) }; + return { redirect: "/passkey?" + paramsPasskey }; } } else { // prefer passkey in favor of other methods if (methods.authMethodTypes.includes(AuthenticationMethodType.PASSKEY)) { - const passkeyParams: any = { - loginName: command.loginName, + const passkeyParams = new URLSearchParams({ + loginName: session.factors?.user?.loginName, altPassword: `${methods.authMethodTypes.includes(1)}`, // show alternative password option - }; + }); if (command.requestId) { - passkeyParams.requestId = command.requestId; + passkeyParams.append("requestId", command.requestId); } if (command.organization || session.factors?.user?.organizationId) { - passkeyParams.organization = - command.organization ?? session.factors?.user?.organizationId; + passkeyParams.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } - return { redirect: "/passkey?" + new URLSearchParams(passkeyParams) }; + return { redirect: "/passkey?" + passkeyParams }; } else if ( methods.authMethodTypes.includes(AuthenticationMethodType.IDP) ) { @@ -356,19 +364,23 @@ export async function sendLoginname(command: SendLoginnameCommand) { methods.authMethodTypes.includes(AuthenticationMethodType.PASSWORD) ) { // user has no passkey setup and login settings allow passkeys - const paramsPasswordDefault: any = { loginName: command.loginName }; + const paramsPasswordDefault = new URLSearchParams({ + loginName: session.factors?.user?.loginName, + }); if (command.requestId) { - paramsPasswordDefault.requestId = command.requestId; + paramsPasswordDefault.append("requestId", command.requestId); } if (command.organization || session.factors?.user?.organizationId) { - paramsPasswordDefault.organization = - command.organization ?? session.factors?.user?.organizationId; + paramsPasswordDefault.append( + "organization", + command.organization ?? session.factors?.user?.organizationId, + ); } return { - redirect: "/password?" + new URLSearchParams(paramsPasswordDefault), + redirect: "/password?" + paramsPasswordDefault, }; } } diff --git a/login/apps/login/src/lib/session.ts b/login/apps/login/src/lib/session.ts index 9698c4c4ba..8c2548b8fb 100644 --- a/login/apps/login/src/lib/session.ts +++ b/login/apps/login/src/lib/session.ts @@ -13,7 +13,6 @@ import { type LoadMostRecentSessionParams = { serviceUrl: string; - sessionParams: { loginName?: string; organization?: string; diff --git a/login/apps/login/src/lib/zitadel.ts b/login/apps/login/src/lib/zitadel.ts index 483d4e4ac9..442c2be85c 100644 --- a/login/apps/login/src/lib/zitadel.ts +++ b/login/apps/login/src/lib/zitadel.ts @@ -854,15 +854,15 @@ export async function searchUsers({ const emailQuery = EmailQuery(searchValue); emailAndPhoneQueries.push(emailQuery); } else { - const emailAndPhoneOrQueries: SearchQuery[] = []; + const orQuery: SearchQuery[] = []; const emailQuery = EmailQuery(searchValue); - emailAndPhoneOrQueries.push(emailQuery); + orQuery.push(emailQuery); let phoneQuery; if (searchValue.length <= 20) { phoneQuery = PhoneQuery(searchValue); - emailAndPhoneOrQueries.push(phoneQuery); + orQuery.push(phoneQuery); } emailAndPhoneQueries.push( @@ -870,7 +870,7 @@ export async function searchUsers({ query: { case: "orQuery", value: { - queries: emailAndPhoneOrQueries, + queries: orQuery, }, }, }), @@ -903,7 +903,7 @@ export async function searchUsers({ } if (emailOrPhoneResult.result.length == 1) { - return loginNameResult; + return emailOrPhoneResult; } return { error: "User not found in the system" }; From 12656235e2ecfd44669abf7b64808f24c14f9b3e Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Thu, 3 Jul 2025 13:10:10 +0200 Subject: [PATCH 120/123] chore: fix login image with sha release (#10157) # Which Problems Are Solved Fixes the releasing of multi-architecture login images. # How the Problems Are Solved - The login-container workflow extends the bake definition with a file docker-bake-release.hcl wich adds the platforms linux/arm and linux/amd to all relevant build targets. The used technique is similar to how the docker metadata action allows to extend the bake definitions. - The local login tag is moved to the metadata bake target, which is always inherited and overwritten in the pipeline - Packages write permission is added # Additional Changes - The MIT license is noted in container labels and annotations - The Image is built from root so that the local proto files are used --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/build.yml | 8 ++--- .github/workflows/login-container.yml | 11 +++++-- login/Makefile | 2 +- .../docker-compose-ci.yaml | 2 +- login/docker-bake-release.hcl | 3 ++ login/docker-bake.hcl | 30 ++++++++++++++----- 6 files changed, 40 insertions(+), 16 deletions(-) create mode 100644 login/docker-bake-release.hcl diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 47aa4adef0..81f3104065 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -86,7 +86,7 @@ jobs: actions: write id-token: write with: - ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' }} + ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' || fromJSON(github.run_attempt) > 1 }} node_version: "20" container: @@ -106,7 +106,7 @@ jobs: packages: write id-token: write with: - login_build_image_name: "ghcr.io/zitadel/login-build" + login_build_image_name: "ghcr.io/zitadel/zitadel-login-build" node_version: "20" e2e: @@ -133,5 +133,5 @@ jobs: image_name: "ghcr.io/zitadel/zitadel" google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel" build_image_name_login: ${{ needs.login-container.outputs.login_build_image }} - image_name_login: "ghcr.io/zitadel/login" - google_image_name_login: europe-docker.pkg.dev/zitadel-common/zitadel-repo/login + image_name_login: "ghcr.io/zitadel/zitadel-login" + google_image_name_login: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel-login" diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml index bce15512af..5cc841bff4 100644 --- a/.github/workflows/login-container.yml +++ b/.github/workflows/login-container.yml @@ -22,6 +22,7 @@ env: default_labels: | org.opencontainers.image.documentation=https://zitadel.com/docs org.opencontainers.image.vendor=CAOS AG + org.opencontainers.image.licenses=MIT jobs: login-container: @@ -29,6 +30,7 @@ jobs: runs-on: depot-ubuntu-22.04-8 permissions: id-token: write + packages: write steps: - uses: actions/checkout@v4 - uses: depot/setup-action@v1 @@ -40,6 +42,8 @@ jobs: with: images: ${{ inputs.login_build_image_name }} labels: ${{ env.default_labels}} + annotations: | + manifest:org.opencontainers.image.licenses=MIT tags: | type=sha,prefix=,suffix=,format=long - name: Login to Docker registry @@ -53,11 +57,14 @@ jobs: env: NODE_VERSION: ${{ inputs.node_version }} with: - workdir: login push: true + provenance: true + sbom: true targets: login-standalone - set: login-standalone.platforms=[linux/amd64,linux/arm64] + set: login-*.context=./login/ project: w47wkxzdtw files: | + ./login/docker-bake.hcl + ./login/docker-bake-release.hcl ./docker-bake.hcl cwd://${{ steps.login-meta.outputs.bake-file }} diff --git a/login/Makefile b/login/Makefile index a6e781374b..05cf704c3f 100644 --- a/login/Makefile +++ b/login/Makefile @@ -14,7 +14,7 @@ export GID := $(id -g) export LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT := $(LOGIN_DIR)apps/login-test-acceptance export DOCKER_METADATA_OUTPUT_VERSION ?= local -export LOGIN_TAG ?= login:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TAG ?= zitadel-login:${DOCKER_METADATA_OUTPUT_VERSION} export LOGIN_TEST_UNIT_TAG := login-test-unit:${DOCKER_METADATA_OUTPUT_VERSION} export LOGIN_TEST_INTEGRATION_TAG := login-test-integration:${DOCKER_METADATA_OUTPUT_VERSION} export LOGIN_TEST_ACCEPTANCE_TAG := login-test-acceptance:${DOCKER_METADATA_OUTPUT_VERSION} diff --git a/login/apps/login-test-acceptance/docker-compose-ci.yaml b/login/apps/login-test-acceptance/docker-compose-ci.yaml index 7a531fcf42..6f5963df43 100644 --- a/login/apps/login-test-acceptance/docker-compose-ci.yaml +++ b/login/apps/login-test-acceptance/docker-compose-ci.yaml @@ -16,7 +16,7 @@ services: ZITADEL_ADMIN_USER: zitadel-admin@zitadel.traefik login: - image: "${LOGIN_TAG:-login:local}" + image: "${LOGIN_TAG:-zitadel-login:local}" container_name: acceptance-login labels: - "traefik.enable=true" diff --git a/login/docker-bake-release.hcl b/login/docker-bake-release.hcl new file mode 100644 index 0000000000..51e1c194f6 --- /dev/null +++ b/login/docker-bake-release.hcl @@ -0,0 +1,3 @@ +target "release" { + platforms = ["linux/amd64", "linux/arm64"] +} diff --git a/login/docker-bake.hcl b/login/docker-bake.hcl index 9520b752fa..b60fd7270a 100644 --- a/login/docker-bake.hcl +++ b/login/docker-bake.hcl @@ -6,12 +6,18 @@ variable "DOCKERFILES_DIR" { default = "dockerfiles/" } +# The release target is overwritten in docker-bake-release.hcl +# It makes sure the image is built for multiple platforms. +# By default the platforms property is empty, so images are only built for the current bake runtime platform. +target "release" {} + # typescript-proto-client is used to generate the client code for the login service. # It is not login-prefixed, so it is easily extendable. # To extend this bake-file.hcl, set the context of all login-prefixed targets to a different directory. # For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/ # The zitadel repository uses this to generate the client and the mock server from local proto files. target "typescript-proto-client" { + inherits = ["release"] dockerfile = "${DOCKERFILES_DIR}typescript-proto-client.Dockerfile" contexts = { # We directly generate and download the client server-side with buf, so we don't need the proto files @@ -37,6 +43,7 @@ target "login-typescript-proto-client-out" { # For example docker bake --file login/docker-bake.hcl --file docker-bake.hcl --set login-*.context=./login/ # The zitadel repository uses this to generate the client and the mock server from local proto files. target "proto-files" { + inherits = ["release"] dockerfile = "${DOCKERFILES_DIR}proto-files.Dockerfile" contexts = { login-pnpm = "target:login-pnpm" @@ -48,6 +55,7 @@ variable "NODE_VERSION" { } target "login-pnpm" { + inherits = ["release"] dockerfile = "${DOCKERFILES_DIR}login-pnpm.Dockerfile" args = { NODE_VERSION = "${NODE_VERSION}" @@ -76,6 +84,7 @@ target "login-test-unit" { } target "login-client" { + inherits = ["release"] dockerfile = "${DOCKERFILES_DIR}login-client.Dockerfile" contexts = { login-pnpm = "target:login-pnpm" @@ -93,7 +102,7 @@ target "core-mock" { contexts = { protos = "target:proto-files" } - tags = ["${LOGIN_CORE_MOCK_TAG}"] + tags = ["${LOGIN_CORE_MOCK_TAG}"] } variable "LOGIN_TEST_INTEGRATION_TAG" { @@ -105,7 +114,7 @@ target "login-test-integration" { contexts = { login-pnpm = "target:login-pnpm" } - tags = ["${LOGIN_TEST_INTEGRATION_TAG}"] + tags = ["${LOGIN_TEST_INTEGRATION_TAG}"] } variable "LOGIN_TEST_ACCEPTANCE_TAG" { @@ -117,28 +126,33 @@ target "login-test-acceptance" { contexts = { login-pnpm = "target:login-pnpm" } - tags = ["${LOGIN_TEST_ACCEPTANCE_TAG}"] + tags = ["${LOGIN_TEST_ACCEPTANCE_TAG}"] } variable "LOGIN_TAG" { default = "zitadel-login:local" } -target "docker-metadata-action" {} +target "docker-metadata-action" { + # In the pipeline, this target is overwritten by the docker metadata action. + tags = ["${LOGIN_TAG}"] +} # We run integration and acceptance tests against the next standalone server for docker. target "login-standalone" { - inherits = ["docker-metadata-action"] + inherits = [ + "docker-metadata-action", + "release", + ] dockerfile = "${DOCKERFILES_DIR}login-standalone.Dockerfile" contexts = { login-client = "target:login-client" } - tags = ["${LOGIN_TAG}"] } target "login-standalone-out" { - inherits = ["login-standalone"] - target = "login-standalone-out" + inherits = ["login-standalone"] + target = "login-standalone-out" output = [ "type=local,dest=${LOGIN_DIR}apps/login/standalone" ] From 26ec29a513a10e77c4ffda9d59e8212f08c69947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Fri, 4 Jul 2025 14:14:37 +0300 Subject: [PATCH 121/123] chore(deps): upgrade oidc and chi for dependabot alert (#10160) # Which Problems Are Solved Solve dependabot alerts for Go packages. # How the Problems Are Solved - Upgrade to latest github.com/zitadel/oidc, which already pulls the fixed version of chi. - Upgrade mapstructure # Additional Changes - none # Additional Context - https://github.com/zitadel/zitadel/security/dependabot/323 - https://github.com/zitadel/zitadel/security/dependabot/324 --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index f0ab6246d6..ee7fb0a33a 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/fatih/color v1.18.0 github.com/fergusstrange/embedded-postgres v1.30.0 github.com/gabriel-vasile/mimetype v1.4.9 - github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/chi/v5 v5.2.2 github.com/go-jose/go-jose/v4 v4.1.0 github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-webauthn/webauthn v0.10.2 @@ -80,7 +80,7 @@ require ( github.com/twilio/twilio-go v1.26.1 github.com/zitadel/exifremove v0.1.0 github.com/zitadel/logging v0.6.2 - github.com/zitadel/oidc/v3 v3.37.0 + github.com/zitadel/oidc/v3 v3.39.1 github.com/zitadel/passwap v0.9.0 github.com/zitadel/saml v0.3.5 github.com/zitadel/schema v1.3.1 @@ -99,8 +99,8 @@ require ( golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 golang.org/x/net v0.40.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.14.0 - golang.org/x/text v0.25.0 + golang.org/x/sync v0.15.0 + golang.org/x/text v0.26.0 google.golang.org/api v0.233.0 google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 google.golang.org/grpc v1.72.1 @@ -127,7 +127,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-redsync/redsync/v4 v4.13.0 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/go-webauthn/x v0.1.9 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum index e2ab9768a6..01acf27c5d 100644 --- a/go.sum +++ b/go.sum @@ -234,8 +234,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= @@ -279,8 +279,8 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4= github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs= github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE= @@ -806,8 +806,8 @@ github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8 github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek= github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= -github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= -github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/oidc/v3 v3.39.1 h1:6QwGwI3yxh4somT7fwRCeT1KOn/HOGv0PA0dFciwJjE= +github.com/zitadel/oidc/v3 v3.39.1/go.mod h1:aH8brOrzoliAybVdfq2xIdGvbtl0j/VsKRNa7WE72gI= github.com/zitadel/passwap v0.9.0 h1:QvDK8OHKdb73C0m+mwXvu87UJSBqix3oFwTVENHdv80= github.com/zitadel/passwap v0.9.0/go.mod h1:6QzwFjDkIr3FfudzSogTOx5Ydhq4046dRJtDM/kX+G8= github.com/zitadel/saml v0.3.5 h1:L1RKWS5y66cGepVxUGjx/WSBOtrtSpRA/J3nn5BJLOY= @@ -944,8 +944,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -988,8 +988,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= From 82cd1cee084685ddb272be1f2a97d4fc872bb851 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 4 Jul 2025 09:45:15 -0400 Subject: [PATCH 122/123] fix(service ping): correct endpoint, validate and randomize default interval (#10166) # Which Problems Are Solved The production endpoint of the service ping was wrong. Additionally we discussed in the sprint review, that we could randomize the default interval to prevent all systems to report data at the very same time and also require a minimal interval. # How the Problems Are Solved - fixed the endpoint - If the interval is set to @daily (default), we generate a random time (minute, hour) as a cron format. - Check if the interval is more than 30min and return an error if not. - Fixed yaml indent on `ResourceCount` # Additional Changes None # Additional Context as discussed internally --- cmd/defaults.yaml | 13 +++-- internal/serviceping/worker.go | 45 +++++++++++++++++- internal/serviceping/worker_test.go | 74 +++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 7 deletions(-) diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index f88616b821..2faf42770b 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -1210,11 +1210,13 @@ ServicePing: # By setting Enabled to false, the service ping is disabled completely. Enabled: true # ZITADEL_SERVICEPING_ENABLED # The endpoint to which the reports are sent. The endpoint is used as a base path. Individual reports are sent to the endpoint with a specific path. - Endpoint: "https://zitadel.cloud/api/ping" # ZITADEL_SERVICEPING_ENDPOINT + Endpoint: "https://zitadel.com/api/ping" # ZITADEL_SERVICEPING_ENDPOINT # Interval at which the service ping is sent to the endpoint. # The interval is in the format of a cron expression. - # By default, it is set to every day at midnight: - Interval: "0 0 * * *" # ZITADEL_SERVICEPING_INTERVAL + # By default, it is set to every daily. + # Note that if the interval is set to `@daily`, we randomize the time to prevent all systems from sending their reports at the same time. + # If you want to send the service ping at a specific time, you can set the interval to a cron expression like "@midnight" or "15 4 * * *". + Interval: "@daily" # ZITADEL_SERVICEPING_INTERVAL # Maximum number of attempts for each individual report to be sent. # If one report fails, it will be retried up to this number of times. # Other reports will still be handled in parallel and have their own retry count. @@ -1231,8 +1233,9 @@ ServicePing: # ResourceCount is a periodic report of the number of resources in ZITADEL. # This includes the number of users, organizations, projects, and other resources. ResourceCount: - Enabled: true # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_ENABLED - BulkSize: 10000 # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_BULKSIZE + Enabled: true # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_ENABLED + # The number of counts that are sent in one batch. + BulkSize: 10000 # ZITADEL_SERVICEPING_TELEMETRY_RESOURCECOUNT_BULKSIZE InternalAuthZ: # Configure the RolePermissionMappings by environment variable using JSON notation: diff --git a/internal/serviceping/worker.go b/internal/serviceping/worker.go index 0156373170..b95dd77fa1 100644 --- a/internal/serviceping/worker.go +++ b/internal/serviceping/worker.go @@ -3,7 +3,10 @@ package serviceping import ( "context" "errors" + "fmt" + "math/rand" "net/http" + "time" "github.com/muhlemmer/gu" "github.com/riverqueue/river" @@ -15,11 +18,13 @@ import ( "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/queue" "github.com/zitadel/zitadel/internal/v2/system" + "github.com/zitadel/zitadel/internal/zerrors" analytics "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta" ) const ( - QueueName = "service_ping_report" + QueueName = "service_ping_report" + minInterval = 30 * time.Minute ) var ( @@ -238,7 +243,7 @@ func Start(config *Config, q *queue.Queue) error { if !config.Enabled { return nil } - schedule, err := cron.ParseStandard(config.Interval) + schedule, err := parseAndValidateSchedule(config.Interval) if err != nil { return err } @@ -250,3 +255,39 @@ func Start(config *Config, q *queue.Queue) error { ) return nil } + +func parseAndValidateSchedule(interval string) (cron.Schedule, error) { + if interval == "@daily" { + interval = randomizeDaily() + } + schedule, err := cron.ParseStandard(interval) + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "SERV-NJqiof", "invalid interval") + } + var intervalDuration time.Duration + switch s := schedule.(type) { + case *cron.SpecSchedule: + // For cron.SpecSchedule, we need to calculate the interval duration + // by getting the next time and subtracting it from the time after that. + // This is because the schedule could be a specific time, that is less than 30 minutes away, + // but still run only once a day and therefore is valid. + next := s.Next(time.Now()) + nextAfter := s.Next(next) + intervalDuration = nextAfter.Sub(next) + case cron.ConstantDelaySchedule: + intervalDuration = s.Delay + } + if intervalDuration < minInterval { + return nil, zerrors.ThrowInvalidArgumentf(nil, "SERV-FJ12", "interval must be at least %s", minInterval) + } + logging.WithFields("interval", interval).Info("scheduling service ping") + return schedule, nil +} + +// randomizeDaily generates a random time for the daily cron job +// to prevent all systems from sending the report at the same time. +func randomizeDaily() string { + minute := rand.Intn(60) + hour := rand.Intn(24) + return fmt.Sprintf("%d %d * * *", minute, hour) +} diff --git a/internal/serviceping/worker_test.go b/internal/serviceping/worker_test.go index 373eee9b6e..f5bd38d3eb 100644 --- a/internal/serviceping/worker_test.go +++ b/internal/serviceping/worker_test.go @@ -1050,3 +1050,77 @@ func TestWorker_Work(t *testing.T) { }) } } + +func Test_parseAndValidateSchedule(t *testing.T) { + type args struct { + interval string + } + tests := []struct { + name string + args args + wantNextStart time.Time + wantNextEnd time.Time + wantErr error + }{ + { + name: "@daily, returns randomized daily schedule", + args: args{ + interval: "@daily", + }, + wantNextStart: time.Now(), + wantNextEnd: time.Now().Add(24 * time.Hour), + }, + { + name: "invalid cron expression, returns error", + args: args{ + interval: "invalid cron", + }, + wantErr: zerrors.ThrowInvalidArgument(nil, "SERV-NJqiof", "invalid interval"), + }, + { + name: "valid cron expression, returns schedule", + args: args{ + interval: "0 0 * * *", + }, + wantNextStart: nextMidnight(), + wantNextEnd: nextMidnight(), + }, + { + name: "valid cron expression (extended syntax), returns schedule", + args: args{ + interval: "@midnight", + }, + wantNextStart: nextMidnight(), + wantNextEnd: nextMidnight(), + }, + { + name: "less than minInterval, returns error", + args: args{ + interval: "0/15 * * * *", + }, + wantErr: zerrors.ThrowInvalidArgumentf(nil, "SERV-FJ12", "interval must be at least %s", minInterval), + }, + { + name: "less than minInterval (extended syntax), returns error", + args: args{ + interval: "@every 15m", + }, + wantErr: zerrors.ThrowInvalidArgumentf(nil, "SERV-FJ12", "interval must be at least %s", minInterval), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAndValidateSchedule(tt.args.interval) + assert.ErrorIs(t, err, tt.wantErr) + if tt.wantErr == nil { + now := time.Now() + assert.WithinRange(t, got.Next(now), tt.wantNextStart, tt.wantNextEnd) + } + }) + } +} + +func nextMidnight() time.Time { + year, month, day := time.Now().Date() + return time.Date(year, month, day+1, 0, 0, 0, 0, time.Local) +} From 9ebf2316c671b21feef39d8c039440041969b916 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Fri, 4 Jul 2025 10:06:20 -0400 Subject: [PATCH 123/123] feat: exchange gRPC server implementation to connectRPC (#10145) # Which Problems Are Solved The current maintained gRPC server in combination with a REST (grpc) gateway is getting harder and harder to maintain. Additionally, there have been and still are issues with supporting / displaying `oneOf`s correctly. We therefore decided to exchange the server implementation to connectRPC, which apart from supporting connect as protocol, also also "standard" gRCP clients as well as HTTP/1.1 / rest like clients, e.g. curl directly call the server without any additional gateway. # How the Problems Are Solved - All v2 services are moved to connectRPC implementation. (v1 services are still served as pure grpc servers) - All gRPC server interceptors were migrated / copied to a corresponding connectRPC interceptor. - API.ListGrpcServices and API. ListGrpcMethods were changed to include the connect services and endpoints. - gRPC server reflection was changed to a `StaticReflector` using the `ListGrpcServices` list. - The `grpc.Server` interfaces was split into different combinations to be able to handle the different cases (grpc server and prefixed gateway, connect server with grpc gateway, connect server only, ...) - Docs of services serving connectRPC only with no additional gateway (instance, webkey, project, app, org v2 beta) are changed to expose that - since the plugin is not yet available on buf, we download it using `postinstall` hook of the docs # Additional Changes - WebKey service is added as v2 service (in addition to the current v2beta) # Additional Context closes #9483 --------- Co-authored-by: Elio Bischof --- .github/workflows/core.yml | 1 + .gitignore | 3 +- Makefile | 3 +- buf.gen.yaml | 2 + cmd/start/start.go | 8 +- docs/.gitignore | 1 + docs/base.yaml | 3 + docs/buf.gen.yaml | 11 +- docs/docusaurus.config.js | 16 +- docs/package.json | 3 +- docs/plugin-download.sh | 21 + docs/sidebars.js | 71 +- docs/yarn.lock | 10 + go.mod | 2 + go.sum | 4 + internal/api/api.go | 85 +- internal/api/grpc/action/v2beta/execution.go | 29 +- internal/api/grpc/action/v2beta/query.go | 25 +- internal/api/grpc/action/v2beta/server.go | 17 +- internal/api/grpc/action/v2beta/target.go | 25 +- internal/api/grpc/app/v2beta/app.go | 79 +- internal/api/grpc/app/v2beta/app_key.go | 23 +- internal/api/grpc/app/v2beta/query.go | 35 +- internal/api/grpc/app/v2beta/server.go | 22 +- internal/api/grpc/feature/v2/feature.go | 51 +- internal/api/grpc/feature/v2/server.go | 17 +- internal/api/grpc/feature/v2beta/feature.go | 51 +- internal/api/grpc/feature/v2beta/server.go | 17 +- internal/api/grpc/gerrors/zitadel_errors.go | 26 + internal/api/grpc/idp/v2/query.go | 7 +- internal/api/grpc/idp/v2/server.go | 17 +- internal/api/grpc/instance/v2beta/domain.go | 33 +- internal/api/grpc/instance/v2beta/instance.go | 17 +- internal/api/grpc/instance/v2beta/query.go | 32 +- internal/api/grpc/instance/v2beta/server.go | 17 +- internal/api/grpc/oidc/v2/oidc.go | 47 +- internal/api/grpc/oidc/v2/server.go | 17 +- internal/api/grpc/oidc/v2beta/oidc.go | 29 +- internal/api/grpc/oidc/v2beta/server.go | 17 +- internal/api/grpc/org/v2/org.go | 12 +- internal/api/grpc/org/v2/org_test.go | 7 +- internal/api/grpc/org/v2/query.go | 22 +- internal/api/grpc/org/v2/server.go | 17 +- internal/api/grpc/org/v2beta/helper.go | 7 +- internal/api/grpc/org/v2beta/org.go | 125 +-- internal/api/grpc/org/v2beta/org_test.go | 7 +- internal/api/grpc/org/v2beta/server.go | 17 +- internal/api/grpc/project/v2beta/project.go | 43 +- .../api/grpc/project/v2beta/project_grant.go | 43 +- .../api/grpc/project/v2beta/project_role.go | 29 +- internal/api/grpc/project/v2beta/query.go | 35 +- internal/api/grpc/project/v2beta/server.go | 22 +- internal/api/grpc/saml/v2/saml.go | 25 +- internal/api/grpc/saml/v2/server.go | 16 +- .../connect_middleware/access_interceptor.go | 57 ++ .../activity_interceptor.go | 52 ++ .../connect_middleware/auth_interceptor.go | 65 ++ .../auth_interceptor_test.go | 318 +++++++ .../connect_middleware/cache_interceptor.go | 31 + .../connect_middleware/call_interceptor.go | 18 + .../connect_middleware/error_interceptor.go | 23 + .../error_interceptor_test.go | 65 ++ .../execution_interceptor.go | 160 ++++ .../execution_interceptor_test.go | 815 ++++++++++++++++++ .../instance_interceptor.go | 107 +++ .../connect_middleware/limits_interceptor.go | 34 + .../connect_middleware/metrics_interceptor.go | 96 +++ .../server/connect_middleware/mock_test.go | 50 ++ .../connect_middleware/quota_interceptor.go | 53 ++ .../connect_middleware/service_interceptor.go | 45 + .../translation_interceptor.go | 48 ++ .../server/connect_middleware/translator.go | 37 + .../validation_interceptor.go | 36 + internal/api/grpc/server/gateway.go | 2 +- internal/api/grpc/server/server.go | 24 +- internal/api/grpc/session/v2/query.go | 17 +- internal/api/grpc/session/v2/server.go | 17 +- internal/api/grpc/session/v2/session.go | 31 +- internal/api/grpc/session/v2beta/server.go | 17 +- internal/api/grpc/session/v2beta/session.go | 47 +- internal/api/grpc/settings/v2/query.go | 85 +- internal/api/grpc/settings/v2/server.go | 16 +- internal/api/grpc/settings/v2/settings.go | 16 +- internal/api/grpc/settings/v2beta/server.go | 16 +- internal/api/grpc/settings/v2beta/settings.go | 85 +- internal/api/grpc/user/v2/email.go | 55 +- internal/api/grpc/user/v2/human.go | 13 +- internal/api/grpc/user/v2/idp_link.go | 32 +- internal/api/grpc/user/v2/intent.go | 33 +- internal/api/grpc/user/v2/key.go | 23 +- internal/api/grpc/user/v2/key_query.go | 11 +- internal/api/grpc/user/v2/machine.go | 13 +- internal/api/grpc/user/v2/otp.go | 26 +- internal/api/grpc/user/v2/passkey.go | 65 +- internal/api/grpc/user/v2/passkey_test.go | 12 +- internal/api/grpc/user/v2/password.go | 30 +- internal/api/grpc/user/v2/pat.go | 21 +- internal/api/grpc/user/v2/pat_query.go | 11 +- internal/api/grpc/user/v2/phone.go | 49 +- internal/api/grpc/user/v2/secret.go | 17 +- internal/api/grpc/user/v2/server.go | 16 +- internal/api/grpc/user/v2/totp.go | 26 +- internal/api/grpc/user/v2/totp_test.go | 2 +- internal/api/grpc/user/v2/u2f.go | 30 +- internal/api/grpc/user/v2/u2f_test.go | 4 +- internal/api/grpc/user/v2/user.go | 131 +-- internal/api/grpc/user/v2/user_query.go | 17 +- internal/api/grpc/user/v2beta/email.go | 41 +- internal/api/grpc/user/v2beta/otp.go | 26 +- internal/api/grpc/user/v2beta/passkey.go | 49 +- internal/api/grpc/user/v2beta/passkey_test.go | 12 +- internal/api/grpc/user/v2beta/password.go | 30 +- internal/api/grpc/user/v2beta/phone.go | 49 +- internal/api/grpc/user/v2beta/query.go | 17 +- internal/api/grpc/user/v2beta/server.go | 16 +- internal/api/grpc/user/v2beta/totp.go | 26 +- internal/api/grpc/user/v2beta/totp_test.go | 2 +- internal/api/grpc/user/v2beta/u2f.go | 22 +- internal/api/grpc/user/v2beta/u2f_test.go | 4 +- internal/api/grpc/user/v2beta/user.go | 115 +-- internal/api/grpc/user/v2beta/user_test.go | 4 +- .../webkey_integration_test.go | 216 +++++ internal/api/grpc/webkey/v2/server.go | 51 ++ internal/api/grpc/webkey/v2/webkey.go | 72 ++ .../api/grpc/webkey/v2/webkey_converter.go | 170 ++++ .../grpc/webkey/v2/webkey_converter_test.go | 494 +++++++++++ internal/api/grpc/webkey/v2beta/server.go | 21 +- internal/api/grpc/webkey/v2beta/webkey.go | 31 +- internal/integration/client.go | 3 + .../protoc-gen-zitadel/zitadel.pb.go.tmpl | 8 + proto/zitadel/system.proto | 14 +- proto/zitadel/webkey/v2/key.proto | 109 +++ proto/zitadel/webkey/v2/webkey_service.proto | 335 +++++++ 133 files changed, 5191 insertions(+), 1187 deletions(-) create mode 100644 docs/base.yaml create mode 100644 docs/plugin-download.sh create mode 100644 internal/api/grpc/server/connect_middleware/access_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/activity_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/auth_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/auth_interceptor_test.go create mode 100644 internal/api/grpc/server/connect_middleware/cache_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/call_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/error_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/error_interceptor_test.go create mode 100644 internal/api/grpc/server/connect_middleware/execution_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/execution_interceptor_test.go create mode 100644 internal/api/grpc/server/connect_middleware/instance_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/limits_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/metrics_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/mock_test.go create mode 100644 internal/api/grpc/server/connect_middleware/quota_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/service_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/translation_interceptor.go create mode 100644 internal/api/grpc/server/connect_middleware/translator.go create mode 100644 internal/api/grpc/server/connect_middleware/validation_interceptor.go create mode 100644 internal/api/grpc/webkey/v2/integration_test/webkey_integration_test.go create mode 100644 internal/api/grpc/webkey/v2/server.go create mode 100644 internal/api/grpc/webkey/v2/webkey.go create mode 100644 internal/api/grpc/webkey/v2/webkey_converter.go create mode 100644 internal/api/grpc/webkey/v2/webkey_converter_test.go create mode 100644 proto/zitadel/webkey/v2/key.proto create mode 100644 proto/zitadel/webkey/v2/webkey_service.proto diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 13e7c0dee7..c864c650a7 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -25,6 +25,7 @@ env: internal/api/assets/router.go openapi/v2 pkg/grpc/**/*.pb.* + pkg/grpc/**/*.connect.go jobs: build: diff --git a/.gitignore b/.gitignore index 23469d4209..0aa6cc1976 100644 --- a/.gitignore +++ b/.gitignore @@ -52,7 +52,8 @@ console/src/app/proto/generated/ !pkg/grpc/protoc/v2/options.pb.go **.proto.mock.go **.pb.*.go -**.gen.go +pkg/**/**.connect.go +**.gen.go openapi/**/*.json /internal/api/assets/authz.go /internal/api/assets/router.go diff --git a/Makefile b/Makefile index 10f52b7c4c..3bad5aa1c6 100644 --- a/Makefile +++ b/Makefile @@ -78,12 +78,13 @@ core_grpc_dependencies: go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@v2.22.0 # https://pkg.go.dev/github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2?tab=versions go install github.com/envoyproxy/protoc-gen-validate@v1.1.0 # https://pkg.go.dev/github.com/envoyproxy/protoc-gen-validate?tab=versions go install github.com/bufbuild/buf/cmd/buf@v1.45.0 # https://pkg.go.dev/github.com/bufbuild/buf/cmd/buf?tab=versions + go install connectrpc.com/connect/cmd/protoc-gen-connect-go@v1.18.1 # https://pkg.go.dev/connectrpc.com/connect/cmd/protoc-gen-connect-go?tab=versions .PHONY: core_api core_api: core_api_generator core_grpc_dependencies buf generate mkdir -p pkg/grpc - cp -r .artifacts/grpc/github.com/zitadel/zitadel/pkg/grpc/* pkg/grpc/ + cp -r .artifacts/grpc/github.com/zitadel/zitadel/pkg/grpc/** pkg/grpc/ mkdir -p openapi/v2/zitadel cp -r .artifacts/grpc/zitadel/ openapi/v2/zitadel diff --git a/buf.gen.yaml b/buf.gen.yaml index 858a1e6404..5a29ba9cd3 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -19,3 +19,5 @@ plugins: out: .artifacts/grpc - plugin: zitadel out: .artifacts/grpc + - plugin: connect-go + out: .artifacts/grpc diff --git a/cmd/start/start.go b/cmd/start/start.go index 06f3554a58..50bb9fbdb3 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -59,7 +59,8 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/system" user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta" - webkey "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" + webkey_v2 "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2" + webkey_v2beta "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/idp" @@ -515,7 +516,10 @@ func startAPIs( if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, webkey_v2beta.CreateServer(commands, queries)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, webkey_v2.CreateServer(commands, queries)); err != nil { return nil, err } if err := apis.RegisterService(ctx, debug_events.CreateServer(commands, queries)); err != nil { diff --git a/docs/.gitignore b/docs/.gitignore index bd99d98c6f..e894d20ec6 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -27,3 +27,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .vercel +/protoc-gen-connect-openapi* diff --git a/docs/base.yaml b/docs/base.yaml new file mode 100644 index 0000000000..dc5b9aa0f9 --- /dev/null +++ b/docs/base.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +info: + version: v2 \ No newline at end of file diff --git a/docs/buf.gen.yaml b/docs/buf.gen.yaml index a628f6e748..b507a2fb9c 100644 --- a/docs/buf.gen.yaml +++ b/docs/buf.gen.yaml @@ -1,11 +1,18 @@ # buf.gen.yaml -version: v1 +version: v2 managed: enabled: true plugins: - - plugin: buf.build/grpc-ecosystem/openapiv2 + - remote: buf.build/grpc-ecosystem/openapiv2 out: .artifacts/openapi opt: - allow_delete_body - remove_internal_comments=true - preserve_rpc_order=true + - local: ./protoc-gen-connect-openapi + out: .artifacts/openapi3 + strategy: all + opt: + - short-service-tags + - ignore-googleapi-http + - base=base.yaml diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index abf5c742a5..ffca8b21de 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -337,7 +337,7 @@ module.exports = { }, webkey_v2: { specPath: - ".artifacts/openapi/zitadel/webkey/v2beta/webkey_service.swagger.json", + ".artifacts/openapi3/zitadel/webkey/v2/webkey_service.openapi.yaml", outputDir: "docs/apis/resources/webkey_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -373,7 +373,7 @@ module.exports = { }, org_v2beta: { specPath: - ".artifacts/openapi/zitadel/org/v2beta/org_service.swagger.json", + ".artifacts/openapi3/zitadel/org/v2beta/org_service.openapi.yaml", outputDir: "docs/apis/resources/org_service_v2beta", sidebarOptions: { groupPathsBy: "tag", @@ -382,16 +382,24 @@ module.exports = { }, project_v2beta: { specPath: - ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", + ".artifacts/openapi3/zitadel/project/v2beta/project_service.openapi.yaml", outputDir: "docs/apis/resources/project_service_v2", sidebarOptions: { groupPathsBy: "tag", categoryLinkSource: "auto", }, }, + application_v2: { + specPath: ".artifacts/openapi3/zitadel/app/v2beta/app_service.openapi.yaml", + outputDir: "docs/apis/resources/application_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, instance_v2: { specPath: - ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", + ".artifacts/openapi3/zitadel/instance/v2beta/instance_service.openapi.yaml", outputDir: "docs/apis/resources/instance_service_v2", sidebarOptions: { groupPathsBy: "tag", diff --git a/docs/package.json b/docs/package.json index 2e1214f378..f799a5e76f 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,8 @@ "generate:apidocs": "docusaurus gen-api-docs all", "generate:configdocs": "cp -r ../cmd/defaults.yaml ./docs/self-hosting/manage/configure/ && cp -r ../cmd/setup/steps.yaml ./docs/self-hosting/manage/configure/", "generate:re-gen": "yarn generate:clean-all && yarn generate", - "generate:clean-all": "docusaurus clean-api-docs all" + "generate:clean-all": "docusaurus clean-api-docs all", + "postinstall": "sh ./plugin-download.sh" }, "dependencies": { "@bufbuild/buf": "^1.14.0", diff --git a/docs/plugin-download.sh b/docs/plugin-download.sh new file mode 100644 index 0000000000..c6de8d702f --- /dev/null +++ b/docs/plugin-download.sh @@ -0,0 +1,21 @@ +echo $(uname -m) + +if [ "$(uname)" = "Darwin" ]; then + curl -L -o protoc-gen-connect-openapi.tar.gz https://github.com/sudorandom/protoc-gen-connect-openapi/releases/download/v0.18.0/protoc-gen-connect-openapi_0.18.0_darwin_all.tar.gz +else + ARCH=$(uname -m) + case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + curl -L -o protoc-gen-connect-openapi.tar.gz https://github.com/sudorandom/protoc-gen-connect-openapi/releases/download/v0.18.0/protoc-gen-connect-openapi_0.18.0_linux_${ARCH}.tar.gz +fi +tar -xvf protoc-gen-connect-openapi.tar.gz \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index fe77ea0af2..a0de30271d 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -16,6 +16,7 @@ const sidebar_api_actions_v2 = require("./docs/apis/resources/action_service_v2/ const sidebar_api_project_service_v2 = require("./docs/apis/resources/project_service_v2/sidebar.ts").default const sidebar_api_webkey_service_v2 = require("./docs/apis/resources/webkey_service_v2/sidebar.ts").default const sidebar_api_instance_service_v2 = require("./docs/apis/resources/instance_service_v2/sidebar.ts").default +const sidebar_api_app_v2 = require("./docs/apis/resources/application_service_v2/sidebar.ts").default module.exports = { guides: [ @@ -806,6 +807,18 @@ module.exports = { }, items: sidebar_api_org_service_v2, }, + { + type: "category", + label: "Organization (Beta)", + link: { + type: "generated-index", + title: "Organization Service beta API", + slug: "/apis/resources/org_service/v2beta", + description: + "This API is intended to manage organizations for ZITADEL. \n", + }, + items: sidebar_api_org_service_v2beta, + }, { type: "category", label: "Identity Provider", @@ -820,19 +833,15 @@ module.exports = { }, { type: "category", - label: "Web key (Beta)", + label: "Web Key", link: { type: "generated-index", - title: "Web Key Service API (Beta)", + title: "Web Key Service API", slug: "/apis/resources/webkey_service_v2", description: "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n" + - "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ "\n"+ - "The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n"+ - "\n"+ - "Please make sure to enable the `web_key` feature flag on your instance to use this service and that you're running ZITADEL V3.", + "The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n", }, items: sidebar_api_webkey_service_v2 }, @@ -857,6 +866,54 @@ module.exports = { }, items: sidebar_api_actions_v2, }, + { + type: "category", + label: "Project (Beta)", + link: { + type: "generated-index", + title: "Project Service API (Beta)", + slug: "/apis/resources/project_service_v2", + description: + "This API is intended to manage projects and subresources for ZITADEL. \n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.", + }, + items: sidebar_api_project_service_v2, + }, + { + type: "category", + label: "Instance (Beta)", + link: { + type: "generated-index", + title: "Instance Service API (Beta)", + slug: "/apis/resources/instance_service_v2", + description: + "This API is intended to manage instances, custom domains and trusted domains in ZITADEL.\n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ + "\n" + + "This v2 of the API provides the same functionalities as the v1, but organised on a per resource basis.\n" + + "The whole functionality related to domains (custom and trusted) has been moved under this instance API." + , + }, + items: sidebar_api_instance_service_v2, + }, + { + type: "category", + label: "App (Beta)", + link: { + type: "generated-index", + title: "Application Service API (Beta)", + slug: "/apis/resources/application_service_v2", + description: + "This API lets you manage Zitadel applications (API, SAML, OIDC).\n"+ + "\n"+ + "The API offers generic endpoints that work for all app types (API, SAML, OIDC), "+ + "\n"+ + "This API is in beta state. It can AND will continue breaking until a stable version is released.\n" + }, + items: sidebar_api_app_v2, + }, ], }, { diff --git a/docs/yarn.lock b/docs/yarn.lock index c48c5b8bd6..307577b44e 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -6121,6 +6121,11 @@ caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001718: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz#312e163553dd70d2c0fb603d74810c85d8ed94a0" integrity sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA== +caniuse-lite@^1.0.30001716: + version "1.0.30001726" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz#a15bd87d5a4bf01f6b6f70ae7c97fdfd28b5ae47" + integrity sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw== + ccount@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" @@ -7503,6 +7508,11 @@ electron-to-chromium@^1.4.796: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.803.tgz#cf55808a5ee12e2a2778bbe8cdc941ef87c2093b" integrity sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g== +electron-to-chromium@^1.5.149: + version "1.5.178" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.178.tgz#6fc4d69eb5275bb13068931448fd822458901fbb" + integrity sha512-wObbz/ar3Bc6e4X5vf0iO8xTN8YAjN/tgiAOJLr7yjYFtP9wAjq8Mb5h0yn6kResir+VYx2DXBj9NNobs0ETSA== + electron-to-chromium@^1.5.160: version "1.5.172" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.172.tgz#fe1d99028d8d6321668d0f1fed61d99ac896259c" diff --git a/go.mod b/go.mod index ee7fb0a33a..22980acfaf 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ toolchain go1.24.1 require ( cloud.google.com/go/profiler v0.4.2 cloud.google.com/go/storage v1.54.0 + connectrpc.com/connect v1.18.1 + connectrpc.com/grpcreflect v1.3.0 dario.cat/mergo v1.0.2 github.com/BurntSushi/toml v1.5.0 github.com/DATA-DOG/go-sqlmock v1.5.2 diff --git a/go.sum b/go.sum index 01acf27c5d..7221111a2b 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= diff --git a/internal/api/api.go b/internal/api/api.go index 62d3e14b35..349e9186bc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,16 +7,18 @@ import ( "sort" "strings" + "connectrpc.com/grpcreflect" "github.com/gorilla/mux" "github.com/improbable-eng/grpc-web/go/grpcweb" "github.com/zitadel/logging" "google.golang.org/grpc" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" - "google.golang.org/grpc/reflection" "github.com/zitadel/zitadel/internal/api/authz" + grpc_api "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware" http_util "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/ui/login" @@ -24,10 +26,16 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/metrics" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" + system_pb "github.com/zitadel/zitadel/pkg/grpc/system" +) + +var ( + metricTypes = []metrics.MetricType{metrics.MetricTypeTotalCount, metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode} ) type API struct { port uint16 + externalDomain string grpcServer *grpc.Server verifier authz.APITokenVerifier health healthCheck @@ -37,16 +45,23 @@ type API struct { healthServer *health.Server accessInterceptor *http_mw.AccessInterceptor queries *query.Queries + authConfig authz.Config + systemAuthZ authz.Config + connectServices map[string][]string } func (a *API) ListGrpcServices() []string { serviceInfo := a.grpcServer.GetServiceInfo() - services := make([]string, len(serviceInfo)) + services := make([]string, len(serviceInfo)+len(a.connectServices)) i := 0 for servicename := range serviceInfo { services[i] = servicename i++ } + for prefix := range a.connectServices { + services[i] = strings.Trim(prefix, "/") + i++ + } sort.Strings(services) return services } @@ -59,6 +74,11 @@ func (a *API) ListGrpcMethods() []string { methods = append(methods, "/"+servicename+"/"+method.Name) } } + for service, methodList := range a.connectServices { + for _, method := range methodList { + methods = append(methods, service+method) + } + } sort.Strings(methods) return methods } @@ -82,12 +102,16 @@ func New( ) (_ *API, err error) { api := &API{ port: port, + externalDomain: externalDomain, verifier: verifier, health: queries, router: router, queries: queries, accessInterceptor: accessInterceptor, hostHeaders: hostHeaders, + authConfig: authZ, + systemAuthZ: systemAuthz, + connectServices: make(map[string][]string), } api.grpcServer = server.CreateServer(api.verifier, systemAuthz, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService()) @@ -100,10 +124,15 @@ func New( api.RegisterHandlerOnPrefix("/debug", api.healthHandler()) api.router.Handle("/", http.RedirectHandler(login.HandlerPrefix, http.StatusFound)) - reflection.Register(api.grpcServer) return api, nil } +func (a *API) serverReflection() { + reflector := grpcreflect.NewStaticReflector(a.ListGrpcServices()...) + a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1(reflector)) + a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1Alpha(reflector)) +} + // RegisterServer registers a grpc service on the grpc server, // creates a new grpc gateway and registers it as a separate http handler // @@ -131,17 +160,50 @@ func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayP // and its gateway on the gateway handler // // used for >= v2 api (e.g. user, session, ...) -func (a *API) RegisterService(ctx context.Context, grpcServer server.Server) error { - grpcServer.RegisterServer(a.grpcServer) - err := server.RegisterGateway(ctx, a.grpcGateway, grpcServer) - if err != nil { - return err +func (a *API) RegisterService(ctx context.Context, srv server.Server) error { + switch service := srv.(type) { + case server.GrpcServer: + service.RegisterServer(a.grpcServer) + case server.ConnectServer: + a.registerConnectServer(service) } - a.verifier.RegisterServer(grpcServer.AppName(), grpcServer.MethodPrefix(), grpcServer.AuthMethods()) - a.healthServer.SetServingStatus(grpcServer.MethodPrefix(), healthpb.HealthCheckResponse_SERVING) + if withGateway, ok := srv.(server.WithGateway); ok { + err := server.RegisterGateway(ctx, a.grpcGateway, withGateway) + if err != nil { + return err + } + } + a.verifier.RegisterServer(srv.AppName(), srv.MethodPrefix(), srv.AuthMethods()) + a.healthServer.SetServingStatus(srv.MethodPrefix(), healthpb.HealthCheckResponse_SERVING) return nil } +func (a *API) registerConnectServer(service server.ConnectServer) { + prefix, handler := service.RegisterConnectServer( + connect_middleware.CallDurationHandler(), + connect_middleware.MetricsHandler(metricTypes, grpc_api.Probes...), + connect_middleware.NoCacheInterceptor(), + connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName), + connect_middleware.AccessStorageInterceptor(a.accessInterceptor.AccessService()), + connect_middleware.ErrorHandler(), + connect_middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName), + connect_middleware.AuthorizationInterceptor(a.verifier, a.systemAuthZ, a.authConfig), + connect_middleware.TranslationHandler(), + connect_middleware.QuotaExhaustedInterceptor(a.accessInterceptor.AccessService(), system_pb.SystemService_ServiceDesc.ServiceName), + connect_middleware.ExecutionHandler(a.queries), + connect_middleware.ValidationHandler(), + connect_middleware.ServiceHandler(), + connect_middleware.ActivityInterceptor(), + ) + methods := service.FileDescriptor().Services().Get(0).Methods() + methodNames := make([]string, methods.Len()) + for i := 0; i < methods.Len(); i++ { + methodNames[i] = string(methods.Get(i).Name()) + } + a.connectServices[prefix] = methodNames + a.RegisterHandlerPrefixes(handler, prefix) +} + // HandleFunc allows registering a [http.HandlerFunc] on an exact // path, instead of prefix like RegisterHandlerOnPrefix. func (a *API) HandleFunc(path string, f http.HandlerFunc) { @@ -173,6 +235,9 @@ func (a *API) registerHealthServer() { } func (a *API) RouteGRPC() { + // since all services are now registered, we can build the grpc server reflection and register the handler + a.serverReflection() + http2Route := a.router. MatcherFunc(func(r *http.Request, _ *mux.RouteMatch) bool { return r.ProtoMajor == 2 diff --git a/internal/api/grpc/action/v2beta/execution.go b/internal/api/grpc/action/v2beta/execution.go index 5477a8128e..3b49ebb364 100644 --- a/internal/api/grpc/action/v2beta/execution.go +++ b/internal/api/grpc/action/v2beta/execution.go @@ -3,6 +3,7 @@ package action import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -13,8 +14,8 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) -func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionRequest) (*action.SetExecutionResponse, error) { - reqTargets := req.GetTargets() +func (s *Server) SetExecution(ctx context.Context, req *connect.Request[action.SetExecutionRequest]) (*connect.Response[action.SetExecutionResponse], error) { + reqTargets := req.Msg.GetTargets() targets := make([]*execution.Target, len(reqTargets)) for i, target := range reqTargets { targets[i] = &execution.Target{Type: domain.ExecutionTargetTypeTarget, Target: target} @@ -25,7 +26,7 @@ func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionReque var err error var details *domain.ObjectDetails instanceID := authz.GetInstance(ctx).InstanceID() - switch t := req.GetCondition().GetConditionType().(type) { + switch t := req.Msg.GetCondition().GetConditionType().(type) { case *action.Condition_Request: cond := executionConditionFromRequest(t.Request) details, err = s.command.SetExecutionRequest(ctx, cond, set, instanceID) @@ -43,27 +44,27 @@ func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionReque if err != nil { return nil, err } - return &action.SetExecutionResponse{ + return connect.NewResponse(&action.SetExecutionResponse{ SetDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) ListExecutionFunctions(ctx context.Context, _ *action.ListExecutionFunctionsRequest) (*action.ListExecutionFunctionsResponse, error) { - return &action.ListExecutionFunctionsResponse{ +func (s *Server) ListExecutionFunctions(ctx context.Context, _ *connect.Request[action.ListExecutionFunctionsRequest]) (*connect.Response[action.ListExecutionFunctionsResponse], error) { + return connect.NewResponse(&action.ListExecutionFunctionsResponse{ Functions: s.ListActionFunctions(), - }, nil + }), nil } -func (s *Server) ListExecutionMethods(ctx context.Context, _ *action.ListExecutionMethodsRequest) (*action.ListExecutionMethodsResponse, error) { - return &action.ListExecutionMethodsResponse{ +func (s *Server) ListExecutionMethods(ctx context.Context, _ *connect.Request[action.ListExecutionMethodsRequest]) (*connect.Response[action.ListExecutionMethodsResponse], error) { + return connect.NewResponse(&action.ListExecutionMethodsResponse{ Methods: s.ListGRPCMethods(), - }, nil + }), nil } -func (s *Server) ListExecutionServices(ctx context.Context, _ *action.ListExecutionServicesRequest) (*action.ListExecutionServicesResponse, error) { - return &action.ListExecutionServicesResponse{ +func (s *Server) ListExecutionServices(ctx context.Context, _ *connect.Request[action.ListExecutionServicesRequest]) (*connect.Response[action.ListExecutionServicesResponse], error) { + return connect.NewResponse(&action.ListExecutionServicesResponse{ Services: s.ListGRPCServices(), - }, nil + }), nil } func executionConditionFromRequest(request *action.RequestExecution) *command.ExecutionAPICondition { diff --git a/internal/api/grpc/action/v2beta/query.go b/internal/api/grpc/action/v2beta/query.go index 1dbe80a8f7..9428b6ab7b 100644 --- a/internal/api/grpc/action/v2beta/query.go +++ b/internal/api/grpc/action/v2beta/query.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -22,14 +23,14 @@ const ( conditionIDEventGroupSegmentCount = 1 ) -func (s *Server) GetTarget(ctx context.Context, req *action.GetTargetRequest) (*action.GetTargetResponse, error) { - resp, err := s.query.GetTargetByID(ctx, req.GetId()) +func (s *Server) GetTarget(ctx context.Context, req *connect.Request[action.GetTargetRequest]) (*connect.Response[action.GetTargetResponse], error) { + resp, err := s.query.GetTargetByID(ctx, req.Msg.GetId()) if err != nil { return nil, err } - return &action.GetTargetResponse{ + return connect.NewResponse(&action.GetTargetResponse{ Target: targetToPb(resp), - }, nil + }), nil } type InstanceContext interface { @@ -41,8 +42,8 @@ type Context interface { GetOwner() InstanceContext } -func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest) (*action.ListTargetsResponse, error) { - queries, err := s.ListTargetsRequestToModel(req) +func (s *Server) ListTargets(ctx context.Context, req *connect.Request[action.ListTargetsRequest]) (*connect.Response[action.ListTargetsResponse], error) { + queries, err := s.ListTargetsRequestToModel(req.Msg) if err != nil { return nil, err } @@ -50,14 +51,14 @@ func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest if err != nil { return nil, err } - return &action.ListTargetsResponse{ + return connect.NewResponse(&action.ListTargetsResponse{ Result: targetsToPb(resp.Targets), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } -func (s *Server) ListExecutions(ctx context.Context, req *action.ListExecutionsRequest) (*action.ListExecutionsResponse, error) { - queries, err := s.ListExecutionsRequestToModel(req) +func (s *Server) ListExecutions(ctx context.Context, req *connect.Request[action.ListExecutionsRequest]) (*connect.Response[action.ListExecutionsResponse], error) { + queries, err := s.ListExecutionsRequestToModel(req.Msg) if err != nil { return nil, err } @@ -65,10 +66,10 @@ func (s *Server) ListExecutions(ctx context.Context, req *action.ListExecutionsR if err != nil { return nil, err } - return &action.ListExecutionsResponse{ + return connect.NewResponse(&action.ListExecutionsResponse{ Result: executionsToPb(resp.Executions), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func targetsToPb(targets []*query.Target) []*action.Target { diff --git a/internal/api/grpc/action/v2beta/server.go b/internal/api/grpc/action/v2beta/server.go index ef0d8eb2ba..440bf842ca 100644 --- a/internal/api/grpc/action/v2beta/server.go +++ b/internal/api/grpc/action/v2beta/server.go @@ -1,7 +1,10 @@ package action import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/action/v2beta/actionconnect" ) -var _ action.ActionServiceServer = (*Server)(nil) +var _ actionconnect.ActionServiceHandler = (*Server)(nil) type Server struct { - action.UnimplementedActionServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -43,8 +46,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - action.RegisterActionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return actionconnect.NewActionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return action.File_zitadel_action_v2beta_action_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/action/v2beta/target.go b/internal/api/grpc/action/v2beta/target.go index 26c88b9683..b13f3461f0 100644 --- a/internal/api/grpc/action/v2beta/target.go +++ b/internal/api/grpc/action/v2beta/target.go @@ -3,6 +3,7 @@ package action import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,8 +14,8 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) -func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetRequest) (*action.CreateTargetResponse, error) { - add := createTargetToCommand(req) +func (s *Server) CreateTarget(ctx context.Context, req *connect.Request[action.CreateTargetRequest]) (*connect.Response[action.CreateTargetResponse], error) { + add := createTargetToCommand(req.Msg) instanceID := authz.GetInstance(ctx).InstanceID() createdAt, err := s.command.AddTarget(ctx, add, instanceID) if err != nil { @@ -24,16 +25,16 @@ func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetReque if !createdAt.IsZero() { creationDate = timestamppb.New(createdAt) } - return &action.CreateTargetResponse{ + return connect.NewResponse(&action.CreateTargetResponse{ Id: add.AggregateID, CreationDate: creationDate, SigningKey: add.SigningKey, - }, nil + }), nil } -func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetRequest) (*action.UpdateTargetResponse, error) { +func (s *Server) UpdateTarget(ctx context.Context, req *connect.Request[action.UpdateTargetRequest]) (*connect.Response[action.UpdateTargetResponse], error) { instanceID := authz.GetInstance(ctx).InstanceID() - update := updateTargetToCommand(req) + update := updateTargetToCommand(req.Msg) changedAt, err := s.command.ChangeTarget(ctx, update, instanceID) if err != nil { return nil, err @@ -42,15 +43,15 @@ func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetReque if !changedAt.IsZero() { changeDate = timestamppb.New(changedAt) } - return &action.UpdateTargetResponse{ + return connect.NewResponse(&action.UpdateTargetResponse{ ChangeDate: changeDate, SigningKey: update.SigningKey, - }, nil + }), nil } -func (s *Server) DeleteTarget(ctx context.Context, req *action.DeleteTargetRequest) (*action.DeleteTargetResponse, error) { +func (s *Server) DeleteTarget(ctx context.Context, req *connect.Request[action.DeleteTargetRequest]) (*connect.Response[action.DeleteTargetResponse], error) { instanceID := authz.GetInstance(ctx).InstanceID() - deletedAt, err := s.command.DeleteTarget(ctx, req.GetId(), instanceID) + deletedAt, err := s.command.DeleteTarget(ctx, req.Msg.GetId(), instanceID) if err != nil { return nil, err } @@ -58,9 +59,9 @@ func (s *Server) DeleteTarget(ctx context.Context, req *action.DeleteTargetReque if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &action.DeleteTargetResponse{ + return connect.NewResponse(&action.DeleteTargetResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { diff --git a/internal/api/grpc/app/v2beta/app.go b/internal/api/grpc/app/v2beta/app.go index 48c602f454..e751bf503f 100644 --- a/internal/api/grpc/app/v2beta/app.go +++ b/internal/api/grpc/app/v2beta/app.go @@ -5,6 +5,7 @@ import ( "strings" "time" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" @@ -13,15 +14,15 @@ import ( app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" ) -func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicationRequest) (*app.CreateApplicationResponse, error) { - switch t := req.GetCreationRequestType().(type) { +func (s *Server) CreateApplication(ctx context.Context, req *connect.Request[app.CreateApplicationRequest]) (*connect.Response[app.CreateApplicationResponse], error) { + switch t := req.Msg.GetCreationRequestType().(type) { case *app.CreateApplicationRequest_ApiRequest: - apiApp, err := s.command.AddAPIApplication(ctx, convert.CreateAPIApplicationRequestToDomain(req.GetName(), req.GetProjectId(), req.GetId(), t.ApiRequest), "") + apiApp, err := s.command.AddAPIApplication(ctx, convert.CreateAPIApplicationRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetId(), t.ApiRequest), "") if err != nil { return nil, err } - return &app.CreateApplicationResponse{ + return connect.NewResponse(&app.CreateApplicationResponse{ AppId: apiApp.AppID, CreationDate: timestamppb.New(apiApp.ChangeDate), CreationResponseType: &app.CreateApplicationResponse_ApiResponse{ @@ -30,10 +31,10 @@ func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicati ClientSecret: apiApp.ClientSecretString, }, }, - }, nil + }), nil case *app.CreateApplicationRequest_OidcRequest: - oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetOidcRequest()) + oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetOidcRequest()) if err != nil { return nil, err } @@ -43,7 +44,7 @@ func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicati return nil, err } - return &app.CreateApplicationResponse{ + return connect.NewResponse(&app.CreateApplicationResponse{ AppId: oidcApp.AppID, CreationDate: timestamppb.New(oidcApp.ChangeDate), CreationResponseType: &app.CreateApplicationResponse_OidcResponse{ @@ -54,10 +55,10 @@ func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicati ComplianceProblems: convert.ComplianceProblemsToLocalizedMessages(oidcApp.Compliance.Problems), }, }, - }, nil + }), nil case *app.CreateApplicationRequest_SamlRequest: - samlAppRequest, err := convert.CreateSAMLAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetSamlRequest()) + samlAppRequest, err := convert.CreateSAMLAppRequestToDomain(req.Msg.GetName(), req.Msg.GetProjectId(), req.Msg.GetSamlRequest()) if err != nil { return nil, err } @@ -67,27 +68,27 @@ func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicati return nil, err } - return &app.CreateApplicationResponse{ + return connect.NewResponse(&app.CreateApplicationResponse{ AppId: samlApp.AppID, CreationDate: timestamppb.New(samlApp.ChangeDate), CreationResponseType: &app.CreateApplicationResponse_SamlResponse{ SamlResponse: &app.CreateSAMLApplicationResponse{}, }, - }, nil + }), nil default: return nil, zerrors.ThrowInvalidArgument(nil, "APP-0iiN46", "unknown app type") } } -func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicationRequest) (*app.UpdateApplicationResponse, error) { +func (s *Server) UpdateApplication(ctx context.Context, req *connect.Request[app.UpdateApplicationRequest]) (*connect.Response[app.UpdateApplicationResponse], error) { var changedTime time.Time - if name := strings.TrimSpace(req.GetName()); name != "" { + if name := strings.TrimSpace(req.Msg.GetName()); name != "" { updatedDetails, err := s.command.UpdateApplicationName( ctx, - req.GetProjectId(), + req.Msg.GetProjectId(), &domain.ChangeApp{ - AppID: req.GetId(), + AppID: req.Msg.GetId(), AppName: name, }, "", @@ -99,9 +100,9 @@ func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicati changedTime = updatedDetails.EventDate } - switch t := req.GetUpdateRequestType().(type) { + switch t := req.Msg.GetUpdateRequestType().(type) { case *app.UpdateApplicationRequest_ApiConfigurationRequest: - updatedAPIApp, err := s.command.UpdateAPIApplication(ctx, convert.UpdateAPIApplicationConfigurationRequestToDomain(req.GetId(), req.GetProjectId(), t.ApiConfigurationRequest), "") + updatedAPIApp, err := s.command.UpdateAPIApplication(ctx, convert.UpdateAPIApplicationConfigurationRequestToDomain(req.Msg.GetId(), req.Msg.GetProjectId(), t.ApiConfigurationRequest), "") if err != nil { return nil, err } @@ -109,7 +110,7 @@ func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicati changedTime = updatedAPIApp.ChangeDate case *app.UpdateApplicationRequest_OidcConfigurationRequest: - oidcApp, err := convert.UpdateOIDCAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.OidcConfigurationRequest) + oidcApp, err := convert.UpdateOIDCAppConfigRequestToDomain(req.Msg.GetId(), req.Msg.GetProjectId(), t.OidcConfigurationRequest) if err != nil { return nil, err } @@ -122,7 +123,7 @@ func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicati changedTime = updatedOIDCApp.ChangeDate case *app.UpdateApplicationRequest_SamlConfigurationRequest: - samlApp, err := convert.UpdateSAMLAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.SamlConfigurationRequest) + samlApp, err := convert.UpdateSAMLAppConfigRequestToDomain(req.Msg.GetId(), req.Msg.GetProjectId(), t.SamlConfigurationRequest) if err != nil { return nil, err } @@ -135,53 +136,53 @@ func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicati changedTime = updatedSAMLApp.ChangeDate } - return &app.UpdateApplicationResponse{ + return connect.NewResponse(&app.UpdateApplicationResponse{ ChangeDate: timestamppb.New(changedTime), - }, nil + }), nil } -func (s *Server) DeleteApplication(ctx context.Context, req *app.DeleteApplicationRequest) (*app.DeleteApplicationResponse, error) { - details, err := s.command.RemoveApplication(ctx, req.GetProjectId(), req.GetId(), "") +func (s *Server) DeleteApplication(ctx context.Context, req *connect.Request[app.DeleteApplicationRequest]) (*connect.Response[app.DeleteApplicationResponse], error) { + details, err := s.command.RemoveApplication(ctx, req.Msg.GetProjectId(), req.Msg.GetId(), "") if err != nil { return nil, err } - return &app.DeleteApplicationResponse{ + return connect.NewResponse(&app.DeleteApplicationResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) DeactivateApplication(ctx context.Context, req *app.DeactivateApplicationRequest) (*app.DeactivateApplicationResponse, error) { - details, err := s.command.DeactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") +func (s *Server) DeactivateApplication(ctx context.Context, req *connect.Request[app.DeactivateApplicationRequest]) (*connect.Response[app.DeactivateApplicationResponse], error) { + details, err := s.command.DeactivateApplication(ctx, req.Msg.GetProjectId(), req.Msg.GetId(), "") if err != nil { return nil, err } - return &app.DeactivateApplicationResponse{ + return connect.NewResponse(&app.DeactivateApplicationResponse{ DeactivationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) ReactivateApplication(ctx context.Context, req *app.ReactivateApplicationRequest) (*app.ReactivateApplicationResponse, error) { - details, err := s.command.ReactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") +func (s *Server) ReactivateApplication(ctx context.Context, req *connect.Request[app.ReactivateApplicationRequest]) (*connect.Response[app.ReactivateApplicationResponse], error) { + details, err := s.command.ReactivateApplication(ctx, req.Msg.GetProjectId(), req.Msg.GetId(), "") if err != nil { return nil, err } - return &app.ReactivateApplicationResponse{ + return connect.NewResponse(&app.ReactivateApplicationResponse{ ReactivationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) RegenerateClientSecret(ctx context.Context, req *app.RegenerateClientSecretRequest) (*app.RegenerateClientSecretResponse, error) { +func (s *Server) RegenerateClientSecret(ctx context.Context, req *connect.Request[app.RegenerateClientSecretRequest]) (*connect.Response[app.RegenerateClientSecretResponse], error) { var secret string var changeDate time.Time - switch req.GetAppType().(type) { + switch req.Msg.GetAppType().(type) { case *app.RegenerateClientSecretRequest_IsApi: - config, err := s.command.ChangeAPIApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + config, err := s.command.ChangeAPIApplicationSecret(ctx, req.Msg.GetProjectId(), req.Msg.GetApplicationId(), "") if err != nil { return nil, err } @@ -189,7 +190,7 @@ func (s *Server) RegenerateClientSecret(ctx context.Context, req *app.Regenerate changeDate = config.ChangeDate case *app.RegenerateClientSecretRequest_IsOidc: - config, err := s.command.ChangeOIDCApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + config, err := s.command.ChangeOIDCApplicationSecret(ctx, req.Msg.GetProjectId(), req.Msg.GetApplicationId(), "") if err != nil { return nil, err } @@ -201,8 +202,8 @@ func (s *Server) RegenerateClientSecret(ctx context.Context, req *app.Regenerate return nil, zerrors.ThrowInvalidArgument(nil, "APP-aLWIzw", "unknown app type") } - return &app.RegenerateClientSecretResponse{ + return connect.NewResponse(&app.RegenerateClientSecretResponse{ ClientSecret: secret, CreationDate: timestamppb.New(changeDate), - }, nil + }), nil } diff --git a/internal/api/grpc/app/v2beta/app_key.go b/internal/api/grpc/app/v2beta/app_key.go index 8c0c1989b2..087ff90916 100644 --- a/internal/api/grpc/app/v2beta/app_key.go +++ b/internal/api/grpc/app/v2beta/app_key.go @@ -4,14 +4,15 @@ import ( "context" "strings" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" ) -func (s *Server) CreateApplicationKey(ctx context.Context, req *app.CreateApplicationKeyRequest) (*app.CreateApplicationKeyResponse, error) { - domainReq := convert.CreateAPIClientKeyRequestToDomain(req) +func (s *Server) CreateApplicationKey(ctx context.Context, req *connect.Request[app.CreateApplicationKeyRequest]) (*connect.Response[app.CreateApplicationKeyResponse], error) { + domainReq := convert.CreateAPIClientKeyRequestToDomain(req.Msg) appKey, err := s.command.AddApplicationKey(ctx, domainReq, "") if err != nil { @@ -23,25 +24,25 @@ func (s *Server) CreateApplicationKey(ctx context.Context, req *app.CreateApplic return nil, err } - return &app.CreateApplicationKeyResponse{ + return connect.NewResponse(&app.CreateApplicationKeyResponse{ Id: appKey.KeyID, CreationDate: timestamppb.New(appKey.ChangeDate), KeyDetails: keyDetails, - }, nil + }), nil } -func (s *Server) DeleteApplicationKey(ctx context.Context, req *app.DeleteApplicationKeyRequest) (*app.DeleteApplicationKeyResponse, error) { +func (s *Server) DeleteApplicationKey(ctx context.Context, req *connect.Request[app.DeleteApplicationKeyRequest]) (*connect.Response[app.DeleteApplicationKeyResponse], error) { deletionDetails, err := s.command.RemoveApplicationKey(ctx, - strings.TrimSpace(req.GetProjectId()), - strings.TrimSpace(req.GetApplicationId()), - strings.TrimSpace(req.GetId()), - strings.TrimSpace(req.GetOrganizationId()), + strings.TrimSpace(req.Msg.GetProjectId()), + strings.TrimSpace(req.Msg.GetApplicationId()), + strings.TrimSpace(req.Msg.GetId()), + strings.TrimSpace(req.Msg.GetOrganizationId()), ) if err != nil { return nil, err } - return &app.DeleteApplicationKeyResponse{ + return connect.NewResponse(&app.DeleteApplicationKeyResponse{ DeletionDate: timestamppb.New(deletionDetails.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/app/v2beta/query.go b/internal/api/grpc/app/v2beta/query.go index 2926884520..ab2a98d14a 100644 --- a/internal/api/grpc/app/v2beta/query.go +++ b/internal/api/grpc/app/v2beta/query.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" @@ -12,19 +13,19 @@ import ( app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" ) -func (s *Server) GetApplication(ctx context.Context, req *app.GetApplicationRequest) (*app.GetApplicationResponse, error) { - res, err := s.query.AppByIDWithPermission(ctx, req.GetId(), false, s.checkPermission) +func (s *Server) GetApplication(ctx context.Context, req *connect.Request[app.GetApplicationRequest]) (*connect.Response[app.GetApplicationResponse], error) { + res, err := s.query.AppByIDWithPermission(ctx, req.Msg.GetId(), false, s.checkPermission) if err != nil { return nil, err } - return &app.GetApplicationResponse{ + return connect.NewResponse(&app.GetApplicationResponse{ App: convert.AppToPb(res), - }, nil + }), nil } -func (s *Server) ListApplications(ctx context.Context, req *app.ListApplicationsRequest) (*app.ListApplicationsResponse, error) { - queries, err := convert.ListApplicationsRequestToModel(s.systemDefaults, req) +func (s *Server) ListApplications(ctx context.Context, req *connect.Request[app.ListApplicationsRequest]) (*connect.Response[app.ListApplicationsResponse], error) { + queries, err := convert.ListApplicationsRequestToModel(s.systemDefaults, req.Msg) if err != nil { return nil, err } @@ -34,32 +35,32 @@ func (s *Server) ListApplications(ctx context.Context, req *app.ListApplications return nil, err } - return &app.ListApplicationsResponse{ + return connect.NewResponse(&app.ListApplicationsResponse{ Applications: convert.AppsToPb(res.Apps), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), - }, nil + }), nil } -func (s *Server) GetApplicationKey(ctx context.Context, req *app.GetApplicationKeyRequest) (*app.GetApplicationKeyResponse, error) { - queries, err := convert.GetApplicationKeyQueriesRequestToDomain(req.GetOrganizationId(), req.GetProjectId(), req.GetApplicationId()) +func (s *Server) GetApplicationKey(ctx context.Context, req *connect.Request[app.GetApplicationKeyRequest]) (*connect.Response[app.GetApplicationKeyResponse], error) { + queries, err := convert.GetApplicationKeyQueriesRequestToDomain(req.Msg.GetOrganizationId(), req.Msg.GetProjectId(), req.Msg.GetApplicationId()) if err != nil { return nil, err } - key, err := s.query.GetAuthNKeyByIDWithPermission(ctx, true, strings.TrimSpace(req.GetId()), s.checkPermission, queries...) + key, err := s.query.GetAuthNKeyByIDWithPermission(ctx, true, strings.TrimSpace(req.Msg.GetId()), s.checkPermission, queries...) if err != nil { return nil, err } - return &app.GetApplicationKeyResponse{ + return connect.NewResponse(&app.GetApplicationKeyResponse{ Id: key.ID, CreationDate: timestamppb.New(key.CreationDate), ExpirationDate: timestamppb.New(key.Expiration), - }, nil + }), nil } -func (s *Server) ListApplicationKeys(ctx context.Context, req *app.ListApplicationKeysRequest) (*app.ListApplicationKeysResponse, error) { - queries, err := convert.ListApplicationKeysRequestToDomain(s.systemDefaults, req) +func (s *Server) ListApplicationKeys(ctx context.Context, req *connect.Request[app.ListApplicationKeysRequest]) (*connect.Response[app.ListApplicationKeysResponse], error) { + queries, err := convert.ListApplicationKeysRequestToDomain(s.systemDefaults, req.Msg) if err != nil { return nil, err } @@ -69,8 +70,8 @@ func (s *Server) ListApplicationKeys(ctx context.Context, req *app.ListApplicati return nil, err } - return &app.ListApplicationKeysResponse{ + return connect.NewResponse(&app.ListApplicationKeysResponse{ Keys: convert.ApplicationKeysToPb(res.AuthNKeys), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), - }, nil + }), nil } diff --git a/internal/api/grpc/app/v2beta/server.go b/internal/api/grpc/app/v2beta/server.go index 8343cbe404..54842070cb 100644 --- a/internal/api/grpc/app/v2beta/server.go +++ b/internal/api/grpc/app/v2beta/server.go @@ -1,21 +1,23 @@ package app import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/app/v2beta/appconnect" ) -var _ app.AppServiceServer = (*Server)(nil) +var _ appconnect.AppServiceHandler = (*Server)(nil) type Server struct { - app.UnimplementedAppServiceServer command *command.Commands query *query.Queries systemDefaults systemdefaults.SystemDefaults @@ -36,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - app.RegisterAppServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return appconnect.NewAppServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return app.File_zitadel_app_v2beta_app_service_proto } func (s *Server) AppName() string { @@ -51,7 +57,3 @@ func (s *Server) MethodPrefix() string { func (s *Server) AuthMethods() authz.MethodMapping { return app.AppService_AuthMethods } - -func (s *Server) RegisterGateway() server.RegisterGatewayFunc { - return app.RegisterAppServiceHandler -} diff --git a/internal/api/grpc/feature/v2/feature.go b/internal/api/grpc/feature/v2/feature.go index f4527689fc..f450f734e4 100644 --- a/internal/api/grpc/feature/v2/feature.go +++ b/internal/api/grpc/feature/v2/feature.go @@ -3,6 +3,7 @@ package feature import ( "context" + "connectrpc.com/connect" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -10,8 +11,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/feature/v2" ) -func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { - features, err := systemFeaturesToCommand(req) +func (s *Server) SetSystemFeatures(ctx context.Context, req *connect.Request[feature.SetSystemFeaturesRequest]) (_ *connect.Response[feature.SetSystemFeaturesResponse], err error) { + features, err := systemFeaturesToCommand(req.Msg) if err != nil { return nil, err } @@ -19,31 +20,31 @@ func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFe if err != nil { return nil, err } - return &feature.SetSystemFeaturesResponse{ + return connect.NewResponse(&feature.SetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetSystemFeatures(ctx context.Context, req *feature.ResetSystemFeaturesRequest) (_ *feature.ResetSystemFeaturesResponse, err error) { +func (s *Server) ResetSystemFeatures(ctx context.Context, req *connect.Request[feature.ResetSystemFeaturesRequest]) (_ *connect.Response[feature.ResetSystemFeaturesResponse], err error) { details, err := s.command.ResetSystemFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetSystemFeaturesResponse{ + return connect.NewResponse(&feature.ResetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFeaturesRequest) (_ *feature.GetSystemFeaturesResponse, err error) { +func (s *Server) GetSystemFeatures(ctx context.Context, req *connect.Request[feature.GetSystemFeaturesRequest]) (_ *connect.Response[feature.GetSystemFeaturesResponse], err error) { f, err := s.query.GetSystemFeatures(ctx) if err != nil { return nil, err } - return systemFeaturesToPb(f), nil + return connect.NewResponse(systemFeaturesToPb(f)), nil } -func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) { - features, err := instanceFeaturesToCommand(req) +func (s *Server) SetInstanceFeatures(ctx context.Context, req *connect.Request[feature.SetInstanceFeaturesRequest]) (_ *connect.Response[feature.SetInstanceFeaturesResponse], err error) { + features, err := instanceFeaturesToCommand(req.Msg) if err != nil { return nil, err } @@ -51,44 +52,44 @@ func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstan if err != nil { return nil, err } - return &feature.SetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.SetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetInstanceFeatures(ctx context.Context, req *feature.ResetInstanceFeaturesRequest) (_ *feature.ResetInstanceFeaturesResponse, err error) { +func (s *Server) ResetInstanceFeatures(ctx context.Context, req *connect.Request[feature.ResetInstanceFeaturesRequest]) (_ *connect.Response[feature.ResetInstanceFeaturesResponse], err error) { details, err := s.command.ResetInstanceFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.ResetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetInstanceFeatures(ctx context.Context, req *feature.GetInstanceFeaturesRequest) (_ *feature.GetInstanceFeaturesResponse, err error) { - f, err := s.query.GetInstanceFeatures(ctx, req.GetInheritance()) +func (s *Server) GetInstanceFeatures(ctx context.Context, req *connect.Request[feature.GetInstanceFeaturesRequest]) (_ *connect.Response[feature.GetInstanceFeaturesResponse], err error) { + f, err := s.query.GetInstanceFeatures(ctx, req.Msg.GetInheritance()) if err != nil { return nil, err } - return instanceFeaturesToPb(f), nil + return connect.NewResponse(instanceFeaturesToPb(f)), nil } -func (s *Server) SetOrganizationFeatures(ctx context.Context, req *feature.SetOrganizationFeaturesRequest) (_ *feature.SetOrganizationFeaturesResponse, err error) { +func (s *Server) SetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.SetOrganizationFeaturesRequest]) (_ *connect.Response[feature.SetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetOrganizationFeatures not implemented") } -func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *feature.ResetOrganizationFeaturesRequest) (_ *feature.ResetOrganizationFeaturesResponse, err error) { +func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.ResetOrganizationFeaturesRequest]) (_ *connect.Response[feature.ResetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetOrganizationFeatures not implemented") } -func (s *Server) GetOrganizationFeatures(ctx context.Context, req *feature.GetOrganizationFeaturesRequest) (_ *feature.GetOrganizationFeaturesResponse, err error) { +func (s *Server) GetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.GetOrganizationFeaturesRequest]) (_ *connect.Response[feature.GetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetOrganizationFeatures not implemented") } -func (s *Server) SetUserFeatures(ctx context.Context, req *feature.SetUserFeatureRequest) (_ *feature.SetUserFeaturesResponse, err error) { +func (s *Server) SetUserFeatures(ctx context.Context, req *connect.Request[feature.SetUserFeatureRequest]) (_ *connect.Response[feature.SetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetUserFeatures not implemented") } -func (s *Server) ResetUserFeatures(ctx context.Context, req *feature.ResetUserFeaturesRequest) (_ *feature.ResetUserFeaturesResponse, err error) { +func (s *Server) ResetUserFeatures(ctx context.Context, req *connect.Request[feature.ResetUserFeaturesRequest]) (_ *connect.Response[feature.ResetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetUserFeatures not implemented") } -func (s *Server) GetUserFeatures(ctx context.Context, req *feature.GetUserFeaturesRequest) (_ *feature.GetUserFeaturesResponse, err error) { +func (s *Server) GetUserFeatures(ctx context.Context, req *connect.Request[feature.GetUserFeaturesRequest]) (_ *connect.Response[feature.GetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetUserFeatures not implemented") } diff --git a/internal/api/grpc/feature/v2/server.go b/internal/api/grpc/feature/v2/server.go index ab92df5822..3eb4cc6813 100644 --- a/internal/api/grpc/feature/v2/server.go +++ b/internal/api/grpc/feature/v2/server.go @@ -1,17 +1,22 @@ package feature import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2/featureconnect" ) +var _ featureconnect.FeatureServiceHandler = (*Server)(nil) + type Server struct { - feature.UnimplementedFeatureServiceServer command *command.Commands query *query.Queries } @@ -26,8 +31,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - feature.RegisterFeatureServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return featureconnect.NewFeatureServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return feature.File_zitadel_feature_v2_feature_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/feature/v2beta/feature.go b/internal/api/grpc/feature/v2beta/feature.go index b94f8e7de2..4ff51af883 100644 --- a/internal/api/grpc/feature/v2beta/feature.go +++ b/internal/api/grpc/feature/v2beta/feature.go @@ -3,6 +3,7 @@ package feature import ( "context" + "connectrpc.com/connect" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -10,77 +11,77 @@ import ( feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" ) -func (s *Server) SetSystemFeatures(ctx context.Context, req *feature.SetSystemFeaturesRequest) (_ *feature.SetSystemFeaturesResponse, err error) { - details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req)) +func (s *Server) SetSystemFeatures(ctx context.Context, req *connect.Request[feature.SetSystemFeaturesRequest]) (_ *connect.Response[feature.SetSystemFeaturesResponse], err error) { + details, err := s.command.SetSystemFeatures(ctx, systemFeaturesToCommand(req.Msg)) if err != nil { return nil, err } - return &feature.SetSystemFeaturesResponse{ + return connect.NewResponse(&feature.SetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetSystemFeatures(ctx context.Context, req *feature.ResetSystemFeaturesRequest) (_ *feature.ResetSystemFeaturesResponse, err error) { +func (s *Server) ResetSystemFeatures(ctx context.Context, req *connect.Request[feature.ResetSystemFeaturesRequest]) (_ *connect.Response[feature.ResetSystemFeaturesResponse], err error) { details, err := s.command.ResetSystemFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetSystemFeaturesResponse{ + return connect.NewResponse(&feature.ResetSystemFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetSystemFeatures(ctx context.Context, req *feature.GetSystemFeaturesRequest) (_ *feature.GetSystemFeaturesResponse, err error) { +func (s *Server) GetSystemFeatures(ctx context.Context, req *connect.Request[feature.GetSystemFeaturesRequest]) (_ *connect.Response[feature.GetSystemFeaturesResponse], err error) { f, err := s.query.GetSystemFeatures(ctx) if err != nil { return nil, err } - return systemFeaturesToPb(f), nil + return connect.NewResponse(systemFeaturesToPb(f)), nil } -func (s *Server) SetInstanceFeatures(ctx context.Context, req *feature.SetInstanceFeaturesRequest) (_ *feature.SetInstanceFeaturesResponse, err error) { - details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req)) +func (s *Server) SetInstanceFeatures(ctx context.Context, req *connect.Request[feature.SetInstanceFeaturesRequest]) (_ *connect.Response[feature.SetInstanceFeaturesResponse], err error) { + details, err := s.command.SetInstanceFeatures(ctx, instanceFeaturesToCommand(req.Msg)) if err != nil { return nil, err } - return &feature.SetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.SetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ResetInstanceFeatures(ctx context.Context, req *feature.ResetInstanceFeaturesRequest) (_ *feature.ResetInstanceFeaturesResponse, err error) { +func (s *Server) ResetInstanceFeatures(ctx context.Context, req *connect.Request[feature.ResetInstanceFeaturesRequest]) (_ *connect.Response[feature.ResetInstanceFeaturesResponse], err error) { details, err := s.command.ResetInstanceFeatures(ctx) if err != nil { return nil, err } - return &feature.ResetInstanceFeaturesResponse{ + return connect.NewResponse(&feature.ResetInstanceFeaturesResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) GetInstanceFeatures(ctx context.Context, req *feature.GetInstanceFeaturesRequest) (_ *feature.GetInstanceFeaturesResponse, err error) { - f, err := s.query.GetInstanceFeatures(ctx, req.GetInheritance()) +func (s *Server) GetInstanceFeatures(ctx context.Context, req *connect.Request[feature.GetInstanceFeaturesRequest]) (_ *connect.Response[feature.GetInstanceFeaturesResponse], err error) { + f, err := s.query.GetInstanceFeatures(ctx, req.Msg.GetInheritance()) if err != nil { return nil, err } - return instanceFeaturesToPb(f), nil + return connect.NewResponse(instanceFeaturesToPb(f)), nil } -func (s *Server) SetOrganizationFeatures(ctx context.Context, req *feature.SetOrganizationFeaturesRequest) (_ *feature.SetOrganizationFeaturesResponse, err error) { +func (s *Server) SetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.SetOrganizationFeaturesRequest]) (_ *connect.Response[feature.SetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetOrganizationFeatures not implemented") } -func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *feature.ResetOrganizationFeaturesRequest) (_ *feature.ResetOrganizationFeaturesResponse, err error) { +func (s *Server) ResetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.ResetOrganizationFeaturesRequest]) (_ *connect.Response[feature.ResetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetOrganizationFeatures not implemented") } -func (s *Server) GetOrganizationFeatures(ctx context.Context, req *feature.GetOrganizationFeaturesRequest) (_ *feature.GetOrganizationFeaturesResponse, err error) { +func (s *Server) GetOrganizationFeatures(ctx context.Context, req *connect.Request[feature.GetOrganizationFeaturesRequest]) (_ *connect.Response[feature.GetOrganizationFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetOrganizationFeatures not implemented") } -func (s *Server) SetUserFeatures(ctx context.Context, req *feature.SetUserFeatureRequest) (_ *feature.SetUserFeaturesResponse, err error) { +func (s *Server) SetUserFeatures(ctx context.Context, req *connect.Request[feature.SetUserFeatureRequest]) (_ *connect.Response[feature.SetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method SetUserFeatures not implemented") } -func (s *Server) ResetUserFeatures(ctx context.Context, req *feature.ResetUserFeaturesRequest) (_ *feature.ResetUserFeaturesResponse, err error) { +func (s *Server) ResetUserFeatures(ctx context.Context, req *connect.Request[feature.ResetUserFeaturesRequest]) (_ *connect.Response[feature.ResetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method ResetUserFeatures not implemented") } -func (s *Server) GetUserFeatures(ctx context.Context, req *feature.GetUserFeaturesRequest) (_ *feature.GetUserFeaturesResponse, err error) { +func (s *Server) GetUserFeatures(ctx context.Context, req *connect.Request[feature.GetUserFeaturesRequest]) (_ *connect.Response[feature.GetUserFeaturesResponse], err error) { return nil, status.Errorf(codes.Unimplemented, "method GetUserFeatures not implemented") } diff --git a/internal/api/grpc/feature/v2beta/server.go b/internal/api/grpc/feature/v2beta/server.go index 4208c4acfc..29877f77f9 100644 --- a/internal/api/grpc/feature/v2beta/server.go +++ b/internal/api/grpc/feature/v2beta/server.go @@ -1,17 +1,22 @@ package feature import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" feature "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta/featureconnect" ) +var _ featureconnect.FeatureServiceHandler = (*Server)(nil) + type Server struct { - feature.UnimplementedFeatureServiceServer command *command.Commands query *query.Queries } @@ -26,8 +31,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - feature.RegisterFeatureServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return featureconnect.NewFeatureServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return feature.File_zitadel_feature_v2beta_feature_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/gerrors/zitadel_errors.go b/internal/api/grpc/gerrors/zitadel_errors.go index d679054da6..b5d2893062 100644 --- a/internal/api/grpc/gerrors/zitadel_errors.go +++ b/internal/api/grpc/gerrors/zitadel_errors.go @@ -3,10 +3,12 @@ package gerrors import ( "errors" + "connectrpc.com/connect" "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/protoadapt" commandErrors "github.com/zitadel/zitadel/internal/command/errors" @@ -36,6 +38,30 @@ func ZITADELToGRPCError(err error) error { return s.Err() } +func ZITADELToConnectError(err error) error { + if err == nil { + return nil + } + connectError := new(connect.Error) + if errors.As(err, &connectError) { + return err + } + code, key, id, ok := ExtractZITADELError(err) + if !ok { + return status.Convert(err).Err() + } + msg := key + msg += " (" + id + ")" + + errorInfo := getErrorInfo(id, key, err) + + cErr := connect.NewError(connect.Code(code), errors.New(msg)) + if detail, detailErr := connect.NewErrorDetail(errorInfo.(proto.Message)); detailErr == nil { + cErr.AddDetail(detail) + } + return cErr +} + func ExtractZITADELError(err error) (c codes.Code, msg, id string, ok bool) { if err == nil { return codes.OK, "", "", false diff --git a/internal/api/grpc/idp/v2/query.go b/internal/api/grpc/idp/v2/query.go index 082a94d18f..587b1687b9 100644 --- a/internal/api/grpc/idp/v2/query.go +++ b/internal/api/grpc/idp/v2/query.go @@ -3,6 +3,7 @@ package idp import ( "context" + "connectrpc.com/connect" "github.com/crewjam/saml" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/durationpb" @@ -15,12 +16,12 @@ import ( idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" ) -func (s *Server) GetIDPByID(ctx context.Context, req *idp_pb.GetIDPByIDRequest) (*idp_pb.GetIDPByIDResponse, error) { - idp, err := s.query.IDPTemplateByID(ctx, true, req.Id, false, s.checkPermission) +func (s *Server) GetIDPByID(ctx context.Context, req *connect.Request[idp_pb.GetIDPByIDRequest]) (*connect.Response[idp_pb.GetIDPByIDResponse], error) { + idp, err := s.query.IDPTemplateByID(ctx, true, req.Msg.GetId(), false, s.checkPermission) if err != nil { return nil, err } - return &idp_pb.GetIDPByIDResponse{Idp: idpToPb(idp)}, nil + return connect.NewResponse(&idp_pb.GetIDPByIDResponse{Idp: idpToPb(idp)}), nil } func idpToPb(idp *query.IDPTemplate) *idp_pb.IDP { diff --git a/internal/api/grpc/idp/v2/server.go b/internal/api/grpc/idp/v2/server.go index 246e980434..666c39294d 100644 --- a/internal/api/grpc/idp/v2/server.go +++ b/internal/api/grpc/idp/v2/server.go @@ -1,7 +1,10 @@ package idp import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/idp/v2" + "github.com/zitadel/zitadel/pkg/grpc/idp/v2/idpconnect" ) -var _ idp.IdentityProviderServiceServer = (*Server)(nil) +var _ idpconnect.IdentityProviderServiceHandler = (*Server)(nil) type Server struct { - idp.UnimplementedIdentityProviderServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - idp.RegisterIdentityProviderServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return idpconnect.NewIdentityProviderServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return idp.File_zitadel_idp_v2_idp_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/instance/v2beta/domain.go b/internal/api/grpc/instance/v2beta/domain.go index 439c6e5d8d..380ebff5a7 100644 --- a/internal/api/grpc/instance/v2beta/domain.go +++ b/internal/api/grpc/instance/v2beta/domain.go @@ -3,48 +3,49 @@ package instance import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) AddCustomDomain(ctx context.Context, req *instance.AddCustomDomainRequest) (*instance.AddCustomDomainResponse, error) { - details, err := s.command.AddInstanceDomain(ctx, req.GetDomain()) +func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[instance.AddCustomDomainRequest]) (*connect.Response[instance.AddCustomDomainResponse], error) { + details, err := s.command.AddInstanceDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.AddCustomDomainResponse{ + return connect.NewResponse(&instance.AddCustomDomainResponse{ CreationDate: timestamppb.New(details.CreationDate), - }, nil + }), nil } -func (s *Server) RemoveCustomDomain(ctx context.Context, req *instance.RemoveCustomDomainRequest) (*instance.RemoveCustomDomainResponse, error) { - details, err := s.command.RemoveInstanceDomain(ctx, req.GetDomain()) +func (s *Server) RemoveCustomDomain(ctx context.Context, req *connect.Request[instance.RemoveCustomDomainRequest]) (*connect.Response[instance.RemoveCustomDomainResponse], error) { + details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.RemoveCustomDomainResponse{ + return connect.NewResponse(&instance.RemoveCustomDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) AddTrustedDomain(ctx context.Context, req *instance.AddTrustedDomainRequest) (*instance.AddTrustedDomainResponse, error) { - details, err := s.command.AddTrustedDomain(ctx, req.GetDomain()) +func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[instance.AddTrustedDomainRequest]) (*connect.Response[instance.AddTrustedDomainResponse], error) { + details, err := s.command.AddTrustedDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.AddTrustedDomainResponse{ + return connect.NewResponse(&instance.AddTrustedDomainResponse{ CreationDate: timestamppb.New(details.CreationDate), - }, nil + }), nil } -func (s *Server) RemoveTrustedDomain(ctx context.Context, req *instance.RemoveTrustedDomainRequest) (*instance.RemoveTrustedDomainResponse, error) { - details, err := s.command.RemoveTrustedDomain(ctx, req.GetDomain()) +func (s *Server) RemoveTrustedDomain(ctx context.Context, req *connect.Request[instance.RemoveTrustedDomainRequest]) (*connect.Response[instance.RemoveTrustedDomainResponse], error) { + details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.RemoveTrustedDomainResponse{ + return connect.NewResponse(&instance.RemoveTrustedDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/instance.go b/internal/api/grpc/instance/v2beta/instance.go index b1c36e74bb..b3f2d6e478 100644 --- a/internal/api/grpc/instance/v2beta/instance.go +++ b/internal/api/grpc/instance/v2beta/instance.go @@ -3,30 +3,31 @@ package instance import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) DeleteInstance(ctx context.Context, request *instance.DeleteInstanceRequest) (*instance.DeleteInstanceResponse, error) { - obj, err := s.command.RemoveInstance(ctx, request.GetInstanceId()) +func (s *Server) DeleteInstance(ctx context.Context, request *connect.Request[instance.DeleteInstanceRequest]) (*connect.Response[instance.DeleteInstanceResponse], error) { + obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId()) if err != nil { return nil, err } - return &instance.DeleteInstanceResponse{ + return connect.NewResponse(&instance.DeleteInstanceResponse{ DeletionDate: timestamppb.New(obj.EventDate), - }, nil + }), nil } -func (s *Server) UpdateInstance(ctx context.Context, request *instance.UpdateInstanceRequest) (*instance.UpdateInstanceResponse, error) { - obj, err := s.command.UpdateInstance(ctx, request.GetInstanceName()) +func (s *Server) UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) { + obj, err := s.command.UpdateInstance(ctx, request.Msg.GetInstanceName()) if err != nil { return nil, err } - return &instance.UpdateInstanceResponse{ + return connect.NewResponse(&instance.UpdateInstanceResponse{ ChangeDate: timestamppb.New(obj.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/query.go b/internal/api/grpc/instance/v2beta/query.go index 74f79313ea..10716ffda0 100644 --- a/internal/api/grpc/instance/v2beta/query.go +++ b/internal/api/grpc/instance/v2beta/query.go @@ -3,23 +3,25 @@ package instance import ( "context" + "connectrpc.com/connect" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) GetInstance(ctx context.Context, _ *instance.GetInstanceRequest) (*instance.GetInstanceResponse, error) { +func (s *Server) GetInstance(ctx context.Context, _ *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) { inst, err := s.query.Instance(ctx, true) if err != nil { return nil, err } - return &instance.GetInstanceResponse{ + return connect.NewResponse(&instance.GetInstanceResponse{ Instance: ToProtoObject(inst), - }, nil + }), nil } -func (s *Server) ListInstances(ctx context.Context, req *instance.ListInstancesRequest) (*instance.ListInstancesResponse, error) { - queries, err := ListInstancesRequestToModel(req, s.systemDefaults) +func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) { + queries, err := ListInstancesRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -29,14 +31,14 @@ func (s *Server) ListInstances(ctx context.Context, req *instance.ListInstancesR return nil, err } - return &instance.ListInstancesResponse{ + return connect.NewResponse(&instance.ListInstancesResponse{ Instances: InstancesToPb(instances.Instances), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, instances.SearchResponse), - }, nil + }), nil } -func (s *Server) ListCustomDomains(ctx context.Context, req *instance.ListCustomDomainsRequest) (*instance.ListCustomDomainsResponse, error) { - queries, err := ListCustomDomainsRequestToModel(req, s.systemDefaults) +func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[instance.ListCustomDomainsRequest]) (*connect.Response[instance.ListCustomDomainsResponse], error) { + queries, err := ListCustomDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -46,14 +48,14 @@ func (s *Server) ListCustomDomains(ctx context.Context, req *instance.ListCustom return nil, err } - return &instance.ListCustomDomainsResponse{ + return connect.NewResponse(&instance.ListCustomDomainsResponse{ Domains: DomainsToPb(domains.Domains), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), - }, nil + }), nil } -func (s *Server) ListTrustedDomains(ctx context.Context, req *instance.ListTrustedDomainsRequest) (*instance.ListTrustedDomainsResponse, error) { - queries, err := ListTrustedDomainsRequestToModel(req, s.systemDefaults) +func (s *Server) ListTrustedDomains(ctx context.Context, req *connect.Request[instance.ListTrustedDomainsRequest]) (*connect.Response[instance.ListTrustedDomainsResponse], error) { + queries, err := ListTrustedDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -63,8 +65,8 @@ func (s *Server) ListTrustedDomains(ctx context.Context, req *instance.ListTrust return nil, err } - return &instance.ListTrustedDomainsResponse{ + return connect.NewResponse(&instance.ListTrustedDomainsResponse{ TrustedDomain: trustedDomainsToPb(domains.Domains), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/server.go b/internal/api/grpc/instance/v2beta/server.go index aaeaa4cc8f..1fb3513dd6 100644 --- a/internal/api/grpc/instance/v2beta/server.go +++ b/internal/api/grpc/instance/v2beta/server.go @@ -1,7 +1,10 @@ package instance import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta/instanceconnect" ) -var _ instance.InstanceServiceServer = (*Server)(nil) +var _ instanceconnect.InstanceServiceHandler = (*Server)(nil) type Server struct { - instance.UnimplementedInstanceServiceServer command *command.Commands query *query.Queries systemDefaults systemdefaults.SystemDefaults @@ -39,8 +42,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - instance.RegisterInstanceServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return instanceconnect.NewInstanceServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return instance.File_zitadel_instance_v2beta_instance_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index 8612d11558..d56d6da056 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "google.golang.org/protobuf/types/known/durationpb" @@ -18,30 +19,30 @@ import ( oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { - authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true) +func (s *Server) GetAuthRequest(ctx context.Context, req *connect.Request[oidc_pb.GetAuthRequestRequest]) (*connect.Response[oidc_pb.GetAuthRequestResponse], error) { + authRequest, err := s.query.AuthRequestByID(ctx, true, req.Msg.GetAuthRequestId(), true) if err != nil { logging.WithError(err).Error("query authRequest by ID") return nil, err } - return &oidc_pb.GetAuthRequestResponse{ + return connect.NewResponse(&oidc_pb.GetAuthRequestResponse{ AuthRequest: authRequestToPb(authRequest), - }, nil + }), nil } -func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { - switch v := req.GetCallbackKind().(type) { +func (s *Server) CreateCallback(ctx context.Context, req *connect.Request[oidc_pb.CreateCallbackRequest]) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { + switch v := req.Msg.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: - return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + return s.failAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Error) case *oidc_pb.CreateCallbackRequest_Session: - return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + return s.linkSessionToAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) } } -func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb.GetDeviceAuthorizationRequestRequest) (*oidc_pb.GetDeviceAuthorizationRequestResponse, error) { - deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.GetUserCode()) +func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *connect.Request[oidc_pb.GetDeviceAuthorizationRequestRequest]) (*connect.Response[oidc_pb.GetDeviceAuthorizationRequestResponse], error) { + deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.Msg.GetUserCode()) if err != nil { return nil, err } @@ -49,7 +50,7 @@ func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb if err != nil { return nil, err } - return &oidc_pb.GetDeviceAuthorizationRequestResponse{ + return connect.NewResponse(&oidc_pb.GetDeviceAuthorizationRequestResponse{ DeviceAuthorizationRequest: &oidc_pb.DeviceAuthorizationRequest{ Id: base64.RawURLEncoding.EncodeToString(encrypted), ClientId: deviceRequest.ClientID, @@ -57,24 +58,24 @@ func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb AppName: deviceRequest.AppName, ProjectName: deviceRequest.ProjectName, }, - }, nil + }), nil } -func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest) (*oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse, error) { - deviceCode, err := s.deviceCodeFromID(req.GetDeviceAuthorizationId()) +func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *connect.Request[oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest]) (*connect.Response[oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse], error) { + deviceCode, err := s.deviceCodeFromID(req.Msg.GetDeviceAuthorizationId()) if err != nil { return nil, err } - switch req.GetDecision().(type) { + switch req.Msg.GetDecision().(type) { case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session: - _, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.GetSession().GetSessionId(), req.GetSession().GetSessionToken()) + _, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.Msg.GetSession().GetSessionId(), req.Msg.GetSession().GetSessionToken()) case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny: _, err = s.command.CancelDeviceAuth(ctx, deviceCode, domain.DeviceAuthCanceledDenied) } if err != nil { return nil, err } - return &oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}, nil + return connect.NewResponse(&oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}), nil } func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { @@ -136,7 +137,7 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st return nil } -func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -146,13 +147,13 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } -func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -172,10 +173,10 @@ func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID str if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason { diff --git a/internal/api/grpc/oidc/v2/server.go b/internal/api/grpc/oidc/v2/server.go index 99234ee3d7..3d8f78a8ad 100644 --- a/internal/api/grpc/oidc/v2/server.go +++ b/internal/api/grpc/oidc/v2/server.go @@ -1,7 +1,10 @@ package oidc import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -10,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + "github.com/zitadel/zitadel/pkg/grpc/oidc/v2/oidcconnect" ) -var _ oidc_pb.OIDCServiceServer = (*Server)(nil) +var _ oidcconnect.OIDCServiceHandler = (*Server)(nil) type Server struct { - oidc_pb.UnimplementedOIDCServiceServer command *command.Commands query *query.Queries @@ -42,8 +45,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - oidc_pb.RegisterOIDCServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return oidcconnect.NewOIDCServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return oidc_pb.File_zitadel_oidc_v2_oidc_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/oidc/v2beta/oidc.go b/internal/api/grpc/oidc/v2beta/oidc.go index 66c4bee828..432e6f833f 100644 --- a/internal/api/grpc/oidc/v2beta/oidc.go +++ b/internal/api/grpc/oidc/v2beta/oidc.go @@ -3,6 +3,7 @@ package oidc import ( "context" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "google.golang.org/protobuf/types/known/durationpb" @@ -17,15 +18,15 @@ import ( oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { - authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true) +func (s *Server) GetAuthRequest(ctx context.Context, req *connect.Request[oidc_pb.GetAuthRequestRequest]) (*connect.Response[oidc_pb.GetAuthRequestResponse], error) { + authRequest, err := s.query.AuthRequestByID(ctx, true, req.Msg.GetAuthRequestId(), true) if err != nil { logging.WithError(err).Error("query authRequest by ID") return nil, err } - return &oidc_pb.GetAuthRequestResponse{ + return connect.NewResponse(&oidc_pb.GetAuthRequestResponse{ AuthRequest: authRequestToPb(authRequest), - }, nil + }), nil } func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { @@ -73,18 +74,18 @@ func promptToPb(p domain.Prompt) oidc_pb.Prompt { } } -func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { - switch v := req.GetCallbackKind().(type) { +func (s *Server) CreateCallback(ctx context.Context, req *connect.Request[oidc_pb.CreateCallbackRequest]) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { + switch v := req.Msg.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: - return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + return s.failAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Error) case *oidc_pb.CreateCallbackRequest_Session: - return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + return s.linkSessionToAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) } } -func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -94,10 +95,10 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func (s *Server) checkPermission(ctx context.Context, clientID string, userID string) error { @@ -114,7 +115,7 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st return nil } -func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -130,10 +131,10 @@ func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID str if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason { diff --git a/internal/api/grpc/oidc/v2beta/server.go b/internal/api/grpc/oidc/v2beta/server.go index 7595ae927e..5309a5093e 100644 --- a/internal/api/grpc/oidc/v2beta/server.go +++ b/internal/api/grpc/oidc/v2beta/server.go @@ -1,7 +1,10 @@ package oidc import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta/oidcconnect" ) -var _ oidc_pb.OIDCServiceServer = (*Server)(nil) +var _ oidcconnect.OIDCServiceHandler = (*Server)(nil) type Server struct { - oidc_pb.UnimplementedOIDCServiceServer command *command.Commands query *query.Queries @@ -38,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - oidc_pb.RegisterOIDCServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return oidcconnect.NewOIDCServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return oidc_pb.File_zitadel_oidc_v2beta_oidc_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go index b876826365..42832d147f 100644 --- a/internal/api/grpc/org/v2/org.go +++ b/internal/api/grpc/org/v2/org.go @@ -3,6 +3,8 @@ package org import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/api/grpc/user/v2" "github.com/zitadel/zitadel/internal/command" @@ -10,8 +12,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) -func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) { - orgSetup, err := addOrganizationRequestToCommand(request) +func (s *Server) AddOrganization(ctx context.Context, request *connect.Request[org.AddOrganizationRequest]) (*connect.Response[org.AddOrganizationResponse], error) { + orgSetup, err := addOrganizationRequestToCommand(request.Msg) if err != nil { return nil, err } @@ -68,7 +70,7 @@ func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admi } } -func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) { +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *connect.Response[org.AddOrganizationResponse], err error) { admins := make([]*org.AddOrganizationResponse_CreatedAdmin, 0, len(createdOrg.OrgAdmins)) for _, admin := range createdOrg.OrgAdmins { admin, ok := admin.(*command.CreatedOrgAdmin) @@ -80,9 +82,9 @@ func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganiza }) } } - return &org.AddOrganizationResponse{ + return connect.NewResponse(&org.AddOrganizationResponse{ Details: object.DomainToDetailsPb(createdOrg.ObjectDetails), OrganizationId: createdOrg.ObjectDetails.ResourceOwner, CreatedAdmins: admins, - }, nil + }), nil } diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go index 37a3dca41a..564c5597ee 100644 --- a/internal/api/grpc/org/v2/org_test.go +++ b/internal/api/grpc/org/v2/org_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/types/known/timestamppb" @@ -138,7 +139,7 @@ func Test_createdOrganizationToPb(t *testing.T) { tests := []struct { name string args args - want *org.AddOrganizationResponse + want *connect.Response[org.AddOrganizationResponse] wantErr error }{ { @@ -159,7 +160,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - want: &org.AddOrganizationResponse{ + want: connect.NewResponse(&org.AddOrganizationResponse{ Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.New(now), @@ -173,7 +174,7 @@ func Test_createdOrganizationToPb(t *testing.T) { PhoneCode: gu.Ptr("phoneCode"), }, }, - }, + }), }, } for _, tt := range tests { diff --git a/internal/api/grpc/org/v2/query.go b/internal/api/grpc/org/v2/query.go index 27f279d40e..09e2534e8d 100644 --- a/internal/api/grpc/org/v2/query.go +++ b/internal/api/grpc/org/v2/query.go @@ -3,6 +3,8 @@ package org import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" @@ -11,36 +13,36 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) -func (s *Server) ListOrganizations(ctx context.Context, req *org.ListOrganizationsRequest) (*org.ListOrganizationsResponse, error) { +func (s *Server) ListOrganizations(ctx context.Context, req *connect.Request[org.ListOrganizationsRequest]) (*connect.Response[org.ListOrganizationsResponse], error) { queries, err := listOrgRequestToModel(ctx, req) if err != nil { return nil, err } - orgs, err := s.query.SearchOrgs(ctx, queries, s.checkPermission) + orgs, err := s.query.SearchOrgs(ctx, queries.Msg, s.checkPermission) if err != nil { return nil, err } - return &org.ListOrganizationsResponse{ + return connect.NewResponse(&org.ListOrganizationsResponse{ Result: organizationsToPb(orgs.Orgs), Details: object.ToListDetails(orgs.SearchResponse), - }, nil + }), nil } -func listOrgRequestToModel(ctx context.Context, req *org.ListOrganizationsRequest) (*query.OrgSearchQueries, error) { - offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, err := orgQueriesToQuery(ctx, req.Queries) +func listOrgRequestToModel(ctx context.Context, req *connect.Request[org.ListOrganizationsRequest]) (*connect.Response[query.OrgSearchQueries], error) { + offset, limit, asc := object.ListQueryToQuery(req.Msg.Query) + queries, err := orgQueriesToQuery(ctx, req.Msg.Queries) if err != nil { return nil, err } - return &query.OrgSearchQueries{ + return connect.NewResponse(&query.OrgSearchQueries{ SearchRequest: query.SearchRequest{ Offset: offset, Limit: limit, - SortingColumn: fieldNameToOrganizationColumn(req.SortingColumn), + SortingColumn: fieldNameToOrganizationColumn(req.Msg.SortingColumn), Asc: asc, }, Queries: queries, - }, nil + }), nil } func orgQueriesToQuery(ctx context.Context, queries []*org.SearchQuery) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/org/v2/server.go b/internal/api/grpc/org/v2/server.go index 36588f3eb7..6fd318d114 100644 --- a/internal/api/grpc/org/v2/server.go +++ b/internal/api/grpc/org/v2/server.go @@ -1,7 +1,10 @@ package org import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/org/v2" + "github.com/zitadel/zitadel/pkg/grpc/org/v2/orgconnect" ) -var _ org.OrganizationServiceServer = (*Server)(nil) +var _ orgconnect.OrganizationServiceHandler = (*Server)(nil) type Server struct { - org.UnimplementedOrganizationServiceServer command *command.Commands query *query.Queries checkPermission domain.PermissionCheck @@ -34,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - org.RegisterOrganizationServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return orgconnect.NewOrganizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return org.File_zitadel_org_v2_org_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/org/v2beta/helper.go b/internal/api/grpc/org/v2beta/helper.go index 6f47819bb4..77c3130488 100644 --- a/internal/api/grpc/org/v2beta/helper.go +++ b/internal/api/grpc/org/v2beta/helper.go @@ -3,6 +3,7 @@ package org import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" // TODO fix below @@ -71,7 +72,7 @@ func OrgStateToPb(state domain.OrgState) v2beta_org.OrgState { } } -func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrganizationResponse, err error) { +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *connect.Response[org.CreateOrganizationResponse], err error) { admins := make([]*org.OrganizationAdmin, len(createdOrg.OrgAdmins)) for i, admin := range createdOrg.OrgAdmins { switch admin := admin.(type) { @@ -95,11 +96,11 @@ func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrgan } } } - return &org.CreateOrganizationResponse{ + return connect.NewResponse(&org.CreateOrganizationResponse{ CreationDate: timestamppb.New(createdOrg.ObjectDetails.EventDate), Id: createdOrg.ObjectDetails.ResourceOwner, OrganizationAdmins: admins, - }, nil + }), nil } func OrgViewsToPb(orgs []*query.Org) []*v2beta_org.Organization { diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go index 66198757cb..35e1d72d3c 100644 --- a/internal/api/grpc/org/v2beta/org.go +++ b/internal/api/grpc/org/v2beta/org.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" metadata "github.com/zitadel/zitadel/internal/api/grpc/metadata/v2beta" @@ -17,8 +18,8 @@ import ( v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) -func (s *Server) CreateOrganization(ctx context.Context, request *v2beta_org.CreateOrganizationRequest) (*v2beta_org.CreateOrganizationResponse, error) { - orgSetup, err := createOrganizationRequestToCommand(request) +func (s *Server) CreateOrganization(ctx context.Context, request *connect.Request[v2beta_org.CreateOrganizationRequest]) (*connect.Response[v2beta_org.CreateOrganizationResponse], error) { + orgSetup, err := createOrganizationRequestToCommand(request.Msg) if err != nil { return nil, err } @@ -29,19 +30,19 @@ func (s *Server) CreateOrganization(ctx context.Context, request *v2beta_org.Cre return createdOrganizationToPb(createdOrg) } -func (s *Server) UpdateOrganization(ctx context.Context, request *v2beta_org.UpdateOrganizationRequest) (*v2beta_org.UpdateOrganizationResponse, error) { - org, err := s.command.ChangeOrg(ctx, request.Id, request.Name) +func (s *Server) UpdateOrganization(ctx context.Context, request *connect.Request[v2beta_org.UpdateOrganizationRequest]) (*connect.Response[v2beta_org.UpdateOrganizationResponse], error) { + org, err := s.command.ChangeOrg(ctx, request.Msg.GetId(), request.Msg.GetName()) if err != nil { return nil, err } - return &v2beta_org.UpdateOrganizationResponse{ + return connect.NewResponse(&v2beta_org.UpdateOrganizationResponse{ ChangeDate: timestamppb.New(org.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.ListOrganizationsRequest) (*v2beta_org.ListOrganizationsResponse, error) { - queries, err := listOrgRequestToModel(s.systemDefaults, request) +func (s *Server) ListOrganizations(ctx context.Context, request *connect.Request[v2beta_org.ListOrganizationsRequest]) (*connect.Response[v2beta_org.ListOrganizationsResponse], error) { + queries, err := listOrgRequestToModel(s.systemDefaults, request.Msg) if err != nil { return nil, err } @@ -49,107 +50,107 @@ func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.List if err != nil { return nil, err } - return &v2beta_org.ListOrganizationsResponse{ + return connect.NewResponse(&v2beta_org.ListOrganizationsResponse{ Organizations: OrgViewsToPb(orgs.Orgs), Pagination: &filter.PaginationResponse{ TotalResult: orgs.Count, - AppliedLimit: uint64(request.GetPagination().GetLimit()), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganization(ctx context.Context, request *v2beta_org.DeleteOrganizationRequest) (*v2beta_org.DeleteOrganizationResponse, error) { - details, err := s.command.RemoveOrg(ctx, request.Id) +func (s *Server) DeleteOrganization(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationRequest]) (*connect.Response[v2beta_org.DeleteOrganizationResponse], error) { + details, err := s.command.RemoveOrg(ctx, request.Msg.GetId()) if err != nil { var notFoundError *zerrors.NotFoundError if errors.As(err, ¬FoundError) { - return &v2beta_org.DeleteOrganizationResponse{}, nil + return connect.NewResponse(&v2beta_org.DeleteOrganizationResponse{}), nil } return nil, err } - return &v2beta_org.DeleteOrganizationResponse{ + return connect.NewResponse(&v2beta_org.DeleteOrganizationResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) SetOrganizationMetadata(ctx context.Context, request *v2beta_org.SetOrganizationMetadataRequest) (*v2beta_org.SetOrganizationMetadataResponse, error) { - result, err := s.command.BulkSetOrgMetadata(ctx, request.OrganizationId, BulkSetOrgMetadataToDomain(request)...) +func (s *Server) SetOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.SetOrganizationMetadataRequest]) (*connect.Response[v2beta_org.SetOrganizationMetadataResponse], error) { + result, err := s.command.BulkSetOrgMetadata(ctx, request.Msg.GetOrganizationId(), BulkSetOrgMetadataToDomain(request.Msg)...) if err != nil { return nil, err } - return &org.SetOrganizationMetadataResponse{ + return connect.NewResponse(&org.SetOrganizationMetadataResponse{ SetDate: timestamppb.New(result.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizationMetadata(ctx context.Context, request *v2beta_org.ListOrganizationMetadataRequest) (*v2beta_org.ListOrganizationMetadataResponse, error) { - metadataQueries, err := ListOrgMetadataToDomain(s.systemDefaults, request) +func (s *Server) ListOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.ListOrganizationMetadataRequest]) (*connect.Response[v2beta_org.ListOrganizationMetadataResponse], error) { + metadataQueries, err := ListOrgMetadataToDomain(s.systemDefaults, request.Msg) if err != nil { return nil, err } - res, err := s.query.SearchOrgMetadata(ctx, true, request.OrganizationId, metadataQueries, false) + res, err := s.query.SearchOrgMetadata(ctx, true, request.Msg.GetOrganizationId(), metadataQueries, false) if err != nil { return nil, err } - return &v2beta_org.ListOrganizationMetadataResponse{ + return connect.NewResponse(&v2beta_org.ListOrganizationMetadataResponse{ Metadata: metadata.OrgMetadataListToPb(res.Metadata), Pagination: &filter.PaginationResponse{ TotalResult: res.Count, - AppliedLimit: uint64(request.GetPagination().GetLimit()), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *v2beta_org.DeleteOrganizationMetadataRequest) (*v2beta_org.DeleteOrganizationMetadataResponse, error) { - result, err := s.command.BulkRemoveOrgMetadata(ctx, request.OrganizationId, request.Keys...) +func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationMetadataRequest]) (*connect.Response[v2beta_org.DeleteOrganizationMetadataResponse], error) { + result, err := s.command.BulkRemoveOrgMetadata(ctx, request.Msg.GetOrganizationId(), request.Msg.Keys...) if err != nil { return nil, err } - return &v2beta_org.DeleteOrganizationMetadataResponse{ + return connect.NewResponse(&v2beta_org.DeleteOrganizationMetadataResponse{ DeletionDate: timestamppb.New(result.EventDate), - }, nil + }), nil } -func (s *Server) DeactivateOrganization(ctx context.Context, request *org.DeactivateOrganizationRequest) (*org.DeactivateOrganizationResponse, error) { - objectDetails, err := s.command.DeactivateOrg(ctx, request.Id) +func (s *Server) DeactivateOrganization(ctx context.Context, request *connect.Request[org.DeactivateOrganizationRequest]) (*connect.Response[org.DeactivateOrganizationResponse], error) { + objectDetails, err := s.command.DeactivateOrg(ctx, request.Msg.GetId()) if err != nil { return nil, err } - return &org.DeactivateOrganizationResponse{ + return connect.NewResponse(&org.DeactivateOrganizationResponse{ ChangeDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } -func (s *Server) ActivateOrganization(ctx context.Context, request *org.ActivateOrganizationRequest) (*org.ActivateOrganizationResponse, error) { - objectDetails, err := s.command.ReactivateOrg(ctx, request.Id) +func (s *Server) ActivateOrganization(ctx context.Context, request *connect.Request[org.ActivateOrganizationRequest]) (*connect.Response[org.ActivateOrganizationResponse], error) { + objectDetails, err := s.command.ReactivateOrg(ctx, request.Msg.GetId()) if err != nil { return nil, err } - return &org.ActivateOrganizationResponse{ + return connect.NewResponse(&org.ActivateOrganizationResponse{ ChangeDate: timestamppb.New(objectDetails.EventDate), - }, err + }), err } -func (s *Server) AddOrganizationDomain(ctx context.Context, request *org.AddOrganizationDomainRequest) (*org.AddOrganizationDomainResponse, error) { - userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) +func (s *Server) AddOrganizationDomain(ctx context.Context, request *connect.Request[org.AddOrganizationDomainRequest]) (*connect.Response[org.AddOrganizationDomainResponse], error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Msg.GetDomain(), request.Msg.GetOrganizationId()) if err != nil { return nil, err } - details, err := s.command.AddOrgDomain(ctx, request.OrganizationId, request.Domain, userIDs) + details, err := s.command.AddOrgDomain(ctx, request.Msg.GetOrganizationId(), request.Msg.GetDomain(), userIDs) if err != nil { return nil, err } - return &org.AddOrganizationDomainResponse{ + return connect.NewResponse(&org.AddOrganizationDomainResponse{ CreationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizationDomains(ctx context.Context, req *org.ListOrganizationDomainsRequest) (*org.ListOrganizationDomainsResponse, error) { - queries, err := ListOrgDomainsRequestToModel(s.systemDefaults, req) +func (s *Server) ListOrganizationDomains(ctx context.Context, req *connect.Request[org.ListOrganizationDomainsRequest]) (*connect.Response[org.ListOrganizationDomainsResponse], error) { + queries, err := ListOrgDomainsRequestToModel(s.systemDefaults, req.Msg) if err != nil { return nil, err } - orgIDQuery, err := query.NewOrgDomainOrgIDSearchQuery(req.OrganizationId) + orgIDQuery, err := query.NewOrgDomainOrgIDSearchQuery(req.Msg.GetOrganizationId()) if err != nil { return nil, err } @@ -159,48 +160,48 @@ func (s *Server) ListOrganizationDomains(ctx context.Context, req *org.ListOrgan if err != nil { return nil, err } - return &org.ListOrganizationDomainsResponse{ + return connect.NewResponse(&org.ListOrganizationDomainsResponse{ Domains: object.DomainsToPb(domains.Domains), Pagination: &filter.PaginationResponse{ TotalResult: domains.Count, - AppliedLimit: uint64(req.GetPagination().GetLimit()), + AppliedLimit: uint64(req.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganizationDomain(ctx context.Context, req *org.DeleteOrganizationDomainRequest) (*org.DeleteOrganizationDomainResponse, error) { - details, err := s.command.RemoveOrgDomain(ctx, RemoveOrgDomainRequestToDomain(ctx, req)) +func (s *Server) DeleteOrganizationDomain(ctx context.Context, req *connect.Request[org.DeleteOrganizationDomainRequest]) (*connect.Response[org.DeleteOrganizationDomainResponse], error) { + details, err := s.command.RemoveOrgDomain(ctx, RemoveOrgDomainRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &org.DeleteOrganizationDomainResponse{ + return connect.NewResponse(&org.DeleteOrganizationDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, err + }), err } -func (s *Server) GenerateOrganizationDomainValidation(ctx context.Context, req *org.GenerateOrganizationDomainValidationRequest) (*org.GenerateOrganizationDomainValidationResponse, error) { - token, url, err := s.command.GenerateOrgDomainValidation(ctx, GenerateOrgDomainValidationRequestToDomain(ctx, req)) +func (s *Server) GenerateOrganizationDomainValidation(ctx context.Context, req *connect.Request[org.GenerateOrganizationDomainValidationRequest]) (*connect.Response[org.GenerateOrganizationDomainValidationResponse], error) { + token, url, err := s.command.GenerateOrgDomainValidation(ctx, GenerateOrgDomainValidationRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &org.GenerateOrganizationDomainValidationResponse{ + return connect.NewResponse(&org.GenerateOrganizationDomainValidationResponse{ Token: token, Url: url, - }, nil + }), nil } -func (s *Server) VerifyOrganizationDomain(ctx context.Context, request *org.VerifyOrganizationDomainRequest) (*org.VerifyOrganizationDomainResponse, error) { - userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) +func (s *Server) VerifyOrganizationDomain(ctx context.Context, request *connect.Request[org.VerifyOrganizationDomainRequest]) (*connect.Response[org.VerifyOrganizationDomainResponse], error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Msg.GetDomain(), request.Msg.GetOrganizationId()) if err != nil { return nil, err } - details, err := s.command.ValidateOrgDomain(ctx, ValidateOrgDomainRequestToDomain(ctx, request), userIDs) + details, err := s.command.ValidateOrgDomain(ctx, ValidateOrgDomainRequestToDomain(ctx, request.Msg), userIDs) if err != nil { return nil, err } - return &org.VerifyOrganizationDomainResponse{ + return connect.NewResponse(&org.VerifyOrganizationDomainResponse{ ChangeDate: timestamppb.New(details.EventDate), - }, nil + }), nil } func createOrganizationRequestToCommand(request *v2beta_org.CreateOrganizationRequest) (*command.OrgSetup, error) { diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go index 346d6b88c1..85dec79be4 100644 --- a/internal/api/grpc/org/v2beta/org_test.go +++ b/internal/api/grpc/org/v2beta/org_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -138,7 +139,7 @@ func Test_createdOrganizationToPb(t *testing.T) { tests := []struct { name string args args - want *org.CreateOrganizationResponse + want *connect.Response[org.CreateOrganizationResponse] wantErr error }{ { @@ -159,7 +160,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - want: &org.CreateOrganizationResponse{ + want: connect.NewResponse(&org.CreateOrganizationResponse{ CreationDate: timestamppb.New(now), Id: "orgID", OrganizationAdmins: []*org.OrganizationAdmin{ @@ -173,7 +174,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - }, + }), }, } for _, tt := range tests { diff --git a/internal/api/grpc/org/v2beta/server.go b/internal/api/grpc/org/v2beta/server.go index b7e8d4994f..8f9091c7c3 100644 --- a/internal/api/grpc/org/v2beta/server.go +++ b/internal/api/grpc/org/v2beta/server.go @@ -1,7 +1,10 @@ package org import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -10,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/org/v2beta/orgconnect" ) -var _ org.OrganizationServiceServer = (*Server)(nil) +var _ orgconnect.OrganizationServiceHandler = (*Server)(nil) type Server struct { - org.UnimplementedOrganizationServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -38,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - org.RegisterOrganizationServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return orgconnect.NewOrganizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return org.File_zitadel_org_v2beta_org_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/project/v2beta/project.go b/internal/api/grpc/project/v2beta/project.go index 01b478f5be..b3294f1ea6 100644 --- a/internal/api/grpc/project/v2beta/project.go +++ b/internal/api/grpc/project/v2beta/project.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,8 +14,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) CreateProject(ctx context.Context, req *project_pb.CreateProjectRequest) (*project_pb.CreateProjectResponse, error) { - add := projectCreateToCommand(req) +func (s *Server) CreateProject(ctx context.Context, req *connect.Request[project_pb.CreateProjectRequest]) (*connect.Response[project_pb.CreateProjectResponse], error) { + add := projectCreateToCommand(req.Msg) project, err := s.command.AddProject(ctx, add) if err != nil { return nil, err @@ -23,10 +24,10 @@ func (s *Server) CreateProject(ctx context.Context, req *project_pb.CreateProjec if !project.EventDate.IsZero() { creationDate = timestamppb.New(project.EventDate) } - return &project_pb.CreateProjectResponse{ + return connect.NewResponse(&project_pb.CreateProjectResponse{ Id: add.AggregateID, CreationDate: creationDate, - }, nil + }), nil } func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddProject { @@ -60,8 +61,8 @@ func privateLabelingSettingToDomain(setting project_pb.PrivateLabelingSetting) d } } -func (s *Server) UpdateProject(ctx context.Context, req *project_pb.UpdateProjectRequest) (*project_pb.UpdateProjectResponse, error) { - project, err := s.command.ChangeProject(ctx, projectUpdateToCommand(req)) +func (s *Server) UpdateProject(ctx context.Context, req *connect.Request[project_pb.UpdateProjectRequest]) (*connect.Response[project_pb.UpdateProjectResponse], error) { + project, err := s.command.ChangeProject(ctx, projectUpdateToCommand(req.Msg)) if err != nil { return nil, err } @@ -69,9 +70,9 @@ func (s *Server) UpdateProject(ctx context.Context, req *project_pb.UpdateProjec if !project.EventDate.IsZero() { changeDate = timestamppb.New(project.EventDate) } - return &project_pb.UpdateProjectResponse{ + return connect.NewResponse(&project_pb.UpdateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } func projectUpdateToCommand(req *project_pb.UpdateProjectRequest) *command.ChangeProject { @@ -91,13 +92,13 @@ func projectUpdateToCommand(req *project_pb.UpdateProjectRequest) *command.Chang } } -func (s *Server) DeleteProject(ctx context.Context, req *project_pb.DeleteProjectRequest) (*project_pb.DeleteProjectResponse, error) { - userGrantIDs, err := s.userGrantsFromProject(ctx, req.Id) +func (s *Server) DeleteProject(ctx context.Context, req *connect.Request[project_pb.DeleteProjectRequest]) (*connect.Response[project_pb.DeleteProjectResponse], error) { + userGrantIDs, err := s.userGrantsFromProject(ctx, req.Msg.GetId()) if err != nil { return nil, err } - deletedAt, err := s.command.DeleteProject(ctx, req.Id, "", userGrantIDs...) + deletedAt, err := s.command.DeleteProject(ctx, req.Msg.GetId(), "", userGrantIDs...) if err != nil { return nil, err } @@ -105,9 +106,9 @@ func (s *Server) DeleteProject(ctx context.Context, req *project_pb.DeleteProjec if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &project_pb.DeleteProjectResponse{ + return connect.NewResponse(&project_pb.DeleteProjectResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProject(ctx context.Context, projectID string) ([]string, error) { @@ -124,8 +125,8 @@ func (s *Server) userGrantsFromProject(ctx context.Context, projectID string) ([ return userGrantsToIDs(userGrants.UserGrants), nil } -func (s *Server) DeactivateProject(ctx context.Context, req *project_pb.DeactivateProjectRequest) (*project_pb.DeactivateProjectResponse, error) { - details, err := s.command.DeactivateProject(ctx, req.Id, "") +func (s *Server) DeactivateProject(ctx context.Context, req *connect.Request[project_pb.DeactivateProjectRequest]) (*connect.Response[project_pb.DeactivateProjectResponse], error) { + details, err := s.command.DeactivateProject(ctx, req.Msg.GetId(), "") if err != nil { return nil, err } @@ -133,13 +134,13 @@ func (s *Server) DeactivateProject(ctx context.Context, req *project_pb.Deactiva if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.DeactivateProjectResponse{ + return connect.NewResponse(&project_pb.DeactivateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) ActivateProject(ctx context.Context, req *project_pb.ActivateProjectRequest) (*project_pb.ActivateProjectResponse, error) { - details, err := s.command.ReactivateProject(ctx, req.Id, "") +func (s *Server) ActivateProject(ctx context.Context, req *connect.Request[project_pb.ActivateProjectRequest]) (*connect.Response[project_pb.ActivateProjectResponse], error) { + details, err := s.command.ReactivateProject(ctx, req.Msg.GetId(), "") if err != nil { return nil, err } @@ -147,9 +148,9 @@ func (s *Server) ActivateProject(ctx context.Context, req *project_pb.ActivatePr if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.ActivateProjectResponse{ + return connect.NewResponse(&project_pb.ActivateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } func userGrantsToIDs(userGrants []*query.UserGrant) []string { diff --git a/internal/api/grpc/project/v2beta/project_grant.go b/internal/api/grpc/project/v2beta/project_grant.go index 6c3b195c66..555d4bfd27 100644 --- a/internal/api/grpc/project/v2beta/project_grant.go +++ b/internal/api/grpc/project/v2beta/project_grant.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,8 +12,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) CreateProjectGrant(ctx context.Context, req *project_pb.CreateProjectGrantRequest) (*project_pb.CreateProjectGrantResponse, error) { - add := projectGrantCreateToCommand(req) +func (s *Server) CreateProjectGrant(ctx context.Context, req *connect.Request[project_pb.CreateProjectGrantRequest]) (*connect.Response[project_pb.CreateProjectGrantResponse], error) { + add := projectGrantCreateToCommand(req.Msg) project, err := s.command.AddProjectGrant(ctx, add) if err != nil { return nil, err @@ -21,9 +22,9 @@ func (s *Server) CreateProjectGrant(ctx context.Context, req *project_pb.CreateP if !project.EventDate.IsZero() { creationDate = timestamppb.New(project.EventDate) } - return &project_pb.CreateProjectGrantResponse{ + return connect.NewResponse(&project_pb.CreateProjectGrantResponse{ CreationDate: creationDate, - }, nil + }), nil } func projectGrantCreateToCommand(req *project_pb.CreateProjectGrantRequest) *command.AddProjectGrant { @@ -37,8 +38,8 @@ func projectGrantCreateToCommand(req *project_pb.CreateProjectGrantRequest) *com } } -func (s *Server) UpdateProjectGrant(ctx context.Context, req *project_pb.UpdateProjectGrantRequest) (*project_pb.UpdateProjectGrantResponse, error) { - project, err := s.command.ChangeProjectGrant(ctx, projectGrantUpdateToCommand(req)) +func (s *Server) UpdateProjectGrant(ctx context.Context, req *connect.Request[project_pb.UpdateProjectGrantRequest]) (*connect.Response[project_pb.UpdateProjectGrantResponse], error) { + project, err := s.command.ChangeProjectGrant(ctx, projectGrantUpdateToCommand(req.Msg)) if err != nil { return nil, err } @@ -46,9 +47,9 @@ func (s *Server) UpdateProjectGrant(ctx context.Context, req *project_pb.UpdateP if !project.EventDate.IsZero() { changeDate = timestamppb.New(project.EventDate) } - return &project_pb.UpdateProjectGrantResponse{ + return connect.NewResponse(&project_pb.UpdateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *command.ChangeProjectGrant { @@ -61,8 +62,8 @@ func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *com } } -func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.DeactivateProjectGrantRequest) (*project_pb.DeactivateProjectGrantResponse, error) { - details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") +func (s *Server) DeactivateProjectGrant(ctx context.Context, req *connect.Request[project_pb.DeactivateProjectGrantRequest]) (*connect.Response[project_pb.DeactivateProjectGrantResponse], error) { + details, err := s.command.DeactivateProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "") if err != nil { return nil, err } @@ -70,13 +71,13 @@ func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.Dea if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.DeactivateProjectGrantResponse{ + return connect.NewResponse(&project_pb.DeactivateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.ActivateProjectGrantRequest) (*project_pb.ActivateProjectGrantResponse, error) { - details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") +func (s *Server) ActivateProjectGrant(ctx context.Context, req *connect.Request[project_pb.ActivateProjectGrantRequest]) (*connect.Response[project_pb.ActivateProjectGrantResponse], error) { + details, err := s.command.ReactivateProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "") if err != nil { return nil, err } @@ -84,17 +85,17 @@ func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.Activ if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.ActivateProjectGrantResponse{ + return connect.NewResponse(&project_pb.ActivateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteProjectGrantRequest) (*project_pb.DeleteProjectGrantResponse, error) { - userGrantIDs, err := s.userGrantsFromProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId) +func (s *Server) DeleteProjectGrant(ctx context.Context, req *connect.Request[project_pb.DeleteProjectGrantRequest]) (*connect.Response[project_pb.DeleteProjectGrantResponse], error) { + userGrantIDs, err := s.userGrantsFromProjectGrant(ctx, req.Msg.GetProjectId(), req.Msg.GetGrantedOrganizationId()) if err != nil { return nil, err } - details, err := s.command.DeleteProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "", userGrantIDs...) + details, err := s.command.DeleteProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "", userGrantIDs...) if err != nil { return nil, err } @@ -102,9 +103,9 @@ func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteP if !details.EventDate.IsZero() { deletionDate = timestamppb.New(details.EventDate) } - return &project_pb.DeleteProjectGrantResponse{ + return connect.NewResponse(&project_pb.DeleteProjectGrantResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProjectGrant(ctx context.Context, projectID, grantedOrganizationID string) ([]string, error) { diff --git a/internal/api/grpc/project/v2beta/project_role.go b/internal/api/grpc/project/v2beta/project_role.go index 07fc4e9eac..2316ef4028 100644 --- a/internal/api/grpc/project/v2beta/project_role.go +++ b/internal/api/grpc/project/v2beta/project_role.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,8 +12,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) AddProjectRole(ctx context.Context, req *project_pb.AddProjectRoleRequest) (*project_pb.AddProjectRoleResponse, error) { - role, err := s.command.AddProjectRole(ctx, addProjectRoleRequestToCommand(req)) +func (s *Server) AddProjectRole(ctx context.Context, req *connect.Request[project_pb.AddProjectRoleRequest]) (*connect.Response[project_pb.AddProjectRoleResponse], error) { + role, err := s.command.AddProjectRole(ctx, addProjectRoleRequestToCommand(req.Msg)) if err != nil { return nil, err } @@ -20,9 +21,9 @@ func (s *Server) AddProjectRole(ctx context.Context, req *project_pb.AddProjectR if !role.EventDate.IsZero() { creationDate = timestamppb.New(role.EventDate) } - return &project_pb.AddProjectRoleResponse{ + return connect.NewResponse(&project_pb.AddProjectRoleResponse{ CreationDate: creationDate, - }, nil + }), nil } func addProjectRoleRequestToCommand(req *project_pb.AddProjectRoleRequest) *command.AddProjectRole { @@ -41,8 +42,8 @@ func addProjectRoleRequestToCommand(req *project_pb.AddProjectRoleRequest) *comm } } -func (s *Server) UpdateProjectRole(ctx context.Context, req *project_pb.UpdateProjectRoleRequest) (*project_pb.UpdateProjectRoleResponse, error) { - role, err := s.command.ChangeProjectRole(ctx, updateProjectRoleRequestToCommand(req)) +func (s *Server) UpdateProjectRole(ctx context.Context, req *connect.Request[project_pb.UpdateProjectRoleRequest]) (*connect.Response[project_pb.UpdateProjectRoleResponse], error) { + role, err := s.command.ChangeProjectRole(ctx, updateProjectRoleRequestToCommand(req.Msg)) if err != nil { return nil, err } @@ -50,9 +51,9 @@ func (s *Server) UpdateProjectRole(ctx context.Context, req *project_pb.UpdatePr if !role.EventDate.IsZero() { changeDate = timestamppb.New(role.EventDate) } - return &project_pb.UpdateProjectRoleResponse{ + return connect.NewResponse(&project_pb.UpdateProjectRoleResponse{ ChangeDate: changeDate, - }, nil + }), nil } func updateProjectRoleRequestToCommand(req *project_pb.UpdateProjectRoleRequest) *command.ChangeProjectRole { @@ -75,16 +76,16 @@ func updateProjectRoleRequestToCommand(req *project_pb.UpdateProjectRoleRequest) } } -func (s *Server) RemoveProjectRole(ctx context.Context, req *project_pb.RemoveProjectRoleRequest) (*project_pb.RemoveProjectRoleResponse, error) { - userGrantIDs, err := s.userGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) +func (s *Server) RemoveProjectRole(ctx context.Context, req *connect.Request[project_pb.RemoveProjectRoleRequest]) (*connect.Response[project_pb.RemoveProjectRoleResponse], error) { + userGrantIDs, err := s.userGrantsFromProjectAndRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey()) if err != nil { return nil, err } - projectGrantIDs, err := s.projectGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) + projectGrantIDs, err := s.projectGrantsFromProjectAndRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey()) if err != nil { return nil, err } - details, err := s.command.RemoveProjectRole(ctx, req.ProjectId, req.RoleKey, "", projectGrantIDs, userGrantIDs...) + details, err := s.command.RemoveProjectRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey(), "", projectGrantIDs, userGrantIDs...) if err != nil { return nil, err } @@ -92,9 +93,9 @@ func (s *Server) RemoveProjectRole(ctx context.Context, req *project_pb.RemovePr if !details.EventDate.IsZero() { deletionDate = timestamppb.New(details.EventDate) } - return &project_pb.RemoveProjectRoleResponse{ + return connect.NewResponse(&project_pb.RemoveProjectRoleResponse{ RemovalDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProjectAndRole(ctx context.Context, projectID, roleKey string) ([]string, error) { diff --git a/internal/api/grpc/project/v2beta/query.go b/internal/api/grpc/project/v2beta/query.go index 42b69a480e..c736c5a086 100644 --- a/internal/api/grpc/project/v2beta/query.go +++ b/internal/api/grpc/project/v2beta/query.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" @@ -13,18 +14,18 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) GetProject(ctx context.Context, req *project_pb.GetProjectRequest) (*project_pb.GetProjectResponse, error) { - project, err := s.query.GetProjectByIDWithPermission(ctx, true, req.Id, s.checkPermission) +func (s *Server) GetProject(ctx context.Context, req *connect.Request[project_pb.GetProjectRequest]) (*connect.Response[project_pb.GetProjectResponse], error) { + project, err := s.query.GetProjectByIDWithPermission(ctx, true, req.Msg.GetId(), s.checkPermission) if err != nil { return nil, err } - return &project_pb.GetProjectResponse{ + return connect.NewResponse(&project_pb.GetProjectResponse{ Project: projectToPb(project), - }, nil + }), nil } -func (s *Server) ListProjects(ctx context.Context, req *project_pb.ListProjectsRequest) (*project_pb.ListProjectsResponse, error) { - queries, err := s.listProjectRequestToModel(req) +func (s *Server) ListProjects(ctx context.Context, req *connect.Request[project_pb.ListProjectsRequest]) (*connect.Response[project_pb.ListProjectsResponse], error) { + queries, err := s.listProjectRequestToModel(req.Msg) if err != nil { return nil, err } @@ -32,10 +33,10 @@ func (s *Server) ListProjects(ctx context.Context, req *project_pb.ListProjectsR if err != nil { return nil, err } - return &project_pb.ListProjectsResponse{ + return connect.NewResponse(&project_pb.ListProjectsResponse{ Projects: grantedProjectsToPb(resp.GrantedProjects), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectRequestToModel(req *project_pb.ListProjectsRequest) (*query.ProjectAndGrantedProjectSearchQueries, error) { @@ -213,8 +214,8 @@ func privateLabelingSettingToPb(setting domain.PrivateLabelingSetting) project_p } } -func (s *Server) ListProjectGrants(ctx context.Context, req *project_pb.ListProjectGrantsRequest) (*project_pb.ListProjectGrantsResponse, error) { - queries, err := s.listProjectGrantsRequestToModel(req) +func (s *Server) ListProjectGrants(ctx context.Context, req *connect.Request[project_pb.ListProjectGrantsRequest]) (*connect.Response[project_pb.ListProjectGrantsResponse], error) { + queries, err := s.listProjectGrantsRequestToModel(req.Msg) if err != nil { return nil, err } @@ -222,10 +223,10 @@ func (s *Server) ListProjectGrants(ctx context.Context, req *project_pb.ListProj if err != nil { return nil, err } - return &project_pb.ListProjectGrantsResponse{ + return connect.NewResponse(&project_pb.ListProjectGrantsResponse{ ProjectGrants: projectGrantsToPb(resp.ProjectGrants), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectGrantsRequestToModel(req *project_pb.ListProjectGrantsRequest) (*query.ProjectGrantSearchQueries, error) { @@ -329,12 +330,12 @@ func projectGrantStateToPb(state domain.ProjectGrantState) project_pb.ProjectGra } } -func (s *Server) ListProjectRoles(ctx context.Context, req *project_pb.ListProjectRolesRequest) (*project_pb.ListProjectRolesResponse, error) { - queries, err := s.listProjectRolesRequestToModel(req) +func (s *Server) ListProjectRoles(ctx context.Context, req *connect.Request[project_pb.ListProjectRolesRequest]) (*connect.Response[project_pb.ListProjectRolesResponse], error) { + queries, err := s.listProjectRolesRequestToModel(req.Msg) if err != nil { return nil, err } - err = queries.AppendProjectIDQuery(req.ProjectId) + err = queries.AppendProjectIDQuery(req.Msg.GetProjectId()) if err != nil { return nil, err } @@ -342,10 +343,10 @@ func (s *Server) ListProjectRoles(ctx context.Context, req *project_pb.ListProje if err != nil { return nil, err } - return &project_pb.ListProjectRolesResponse{ + return connect.NewResponse(&project_pb.ListProjectRolesResponse{ ProjectRoles: roleViewsToPb(roles.ProjectRoles), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, roles.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectRolesRequestToModel(req *project_pb.ListProjectRolesRequest) (*query.ProjectRoleSearchQueries, error) { diff --git a/internal/api/grpc/project/v2beta/server.go b/internal/api/grpc/project/v2beta/server.go index fe197f9688..12c18ae4c6 100644 --- a/internal/api/grpc/project/v2beta/server.go +++ b/internal/api/grpc/project/v2beta/server.go @@ -1,21 +1,23 @@ package project import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/project/v2beta/projectconnect" ) -var _ project.ProjectServiceServer = (*Server)(nil) +var _ projectconnect.ProjectServiceHandler = (*Server)(nil) type Server struct { - project.UnimplementedProjectServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -39,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - project.RegisterProjectServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return projectconnect.NewProjectServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return project.File_zitadel_project_v2beta_project_service_proto } func (s *Server) AppName() string { @@ -54,7 +60,3 @@ func (s *Server) MethodPrefix() string { func (s *Server) AuthMethods() authz.MethodMapping { return project.ProjectService_AuthMethods } - -func (s *Server) RegisterGateway() server.RegisterGatewayFunc { - return project.RegisterProjectServiceHandler -} diff --git a/internal/api/grpc/saml/v2/saml.go b/internal/api/grpc/saml/v2/saml.go index 43eae5feb1..5491a5e04b 100644 --- a/internal/api/grpc/saml/v2/saml.go +++ b/internal/api/grpc/saml/v2/saml.go @@ -3,6 +3,7 @@ package saml import ( "context" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/saml/pkg/provider" "google.golang.org/protobuf/types/known/timestamppb" @@ -16,15 +17,15 @@ import ( saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" ) -func (s *Server) GetSAMLRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { - authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true) +func (s *Server) GetSAMLRequest(ctx context.Context, req *connect.Request[saml_pb.GetSAMLRequestRequest]) (*connect.Response[saml_pb.GetSAMLRequestResponse], error) { + authRequest, err := s.query.SamlRequestByID(ctx, true, req.Msg.GetSamlRequestId(), true) if err != nil { logging.WithError(err).Error("query samlRequest by ID") return nil, err } - return &saml_pb.GetSAMLRequestResponse{ + return connect.NewResponse(&saml_pb.GetSAMLRequestResponse{ SamlRequest: samlRequestToPb(authRequest), - }, nil + }), nil } func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { @@ -34,18 +35,18 @@ func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { } } -func (s *Server) CreateResponse(ctx context.Context, req *saml_pb.CreateResponseRequest) (*saml_pb.CreateResponseResponse, error) { - switch v := req.GetResponseKind().(type) { +func (s *Server) CreateResponse(ctx context.Context, req *connect.Request[saml_pb.CreateResponseRequest]) (*connect.Response[saml_pb.CreateResponseResponse], error) { + switch v := req.Msg.GetResponseKind().(type) { case *saml_pb.CreateResponseRequest_Error: - return s.failSAMLRequest(ctx, req.GetSamlRequestId(), v.Error) + return s.failSAMLRequest(ctx, req.Msg.GetSamlRequestId(), v.Error) case *saml_pb.CreateResponseRequest_Session: - return s.linkSessionToSAMLRequest(ctx, req.GetSamlRequestId(), v.Session) + return s.linkSessionToSAMLRequest(ctx, req.Msg.GetSamlRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "SAMLv2-0Tfak3fBS0", "verification oneOf %T in method CreateResponse not implemented", v) } } -func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*saml_pb.CreateResponseResponse, error) { +func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*connect.Response[saml_pb.CreateResponseResponse], error) { details, aar, err := s.command.FailSAMLRequest(ctx, samlRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -55,7 +56,7 @@ func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae * if err != nil { return nil, err } - return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil + return connect.NewResponse(createCallbackResponseFromBinding(details, url, body, authReq.RelayState)), nil } func (s *Server) checkPermission(ctx context.Context, issuer string, userID string) error { @@ -72,7 +73,7 @@ func (s *Server) checkPermission(ctx context.Context, issuer string, userID stri return nil } -func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) { +func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*connect.Response[saml_pb.CreateResponseResponse], error) { details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -87,7 +88,7 @@ func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID str if err != nil { return nil, err } - return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil + return connect.NewResponse(createCallbackResponseFromBinding(details, url, body, authReq.RelayState)), nil } func createCallbackResponseFromBinding(details *domain.ObjectDetails, url string, body string, relayState string) *saml_pb.CreateResponseResponse { diff --git a/internal/api/grpc/saml/v2/server.go b/internal/api/grpc/saml/v2/server.go index 62299d88c5..312a7c356a 100644 --- a/internal/api/grpc/saml/v2/server.go +++ b/internal/api/grpc/saml/v2/server.go @@ -1,7 +1,10 @@ package saml import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,9 +12,10 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + "github.com/zitadel/zitadel/pkg/grpc/saml/v2/samlconnect" ) -var _ saml_pb.SAMLServiceServer = (*Server)(nil) +var _ samlconnect.SAMLServiceHandler = (*Server)(nil) type Server struct { saml_pb.UnimplementedSAMLServiceServer @@ -38,8 +42,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - saml_pb.RegisterSAMLServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return samlconnect.NewSAMLServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return saml_pb.File_zitadel_saml_v2_saml_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/server/connect_middleware/access_interceptor.go b/internal/api/grpc/server/connect_middleware/access_interceptor.go new file mode 100644 index 0000000000..a08df59860 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/access_interceptor.go @@ -0,0 +1,57 @@ +package connect_middleware + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/logstore" + "github.com/zitadel/zitadel/internal/logstore/record" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func AccessStorageInterceptor(svc *logstore.Service[*record.AccessLog]) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + if !svc.Enabled() { + return handler(ctx, req) + } + resp, handlerErr := handler(ctx, req) + + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + var respStatus uint32 + if code := connect.CodeOf(handlerErr); code != connect.CodeUnknown { + respStatus = uint32(code) + } + + respHeader := http.Header{} + if resp != nil { + respHeader = resp.Header() + } + instance := authz.GetInstance(ctx) + domainCtx := http_util.DomainContext(ctx) + + r := &record.AccessLog{ + LogDate: time.Now(), + Protocol: record.GRPC, + RequestURL: req.Spec().Procedure, + ResponseStatus: respStatus, + RequestHeaders: req.Header(), + ResponseHeaders: respHeader, + InstanceID: instance.InstanceID(), + ProjectID: instance.ProjectID(), + RequestedDomain: domainCtx.RequestedDomain(), + RequestedHost: domainCtx.RequestedHost(), + } + + svc.Handle(interceptorCtx, r) + return resp, handlerErr + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/activity_interceptor.go b/internal/api/grpc/server/connect_middleware/activity_interceptor.go new file mode 100644 index 0000000000..4ba6044645 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/activity_interceptor.go @@ -0,0 +1,52 @@ +package connect_middleware + +import ( + "context" + "net/http" + "slices" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/activity" + "github.com/zitadel/zitadel/internal/api/grpc/gerrors" + ainfo "github.com/zitadel/zitadel/internal/api/info" +) + +func ActivityInterceptor() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx = activityInfoFromGateway(ctx, req.Header()).SetMethod(req.Spec().Procedure).IntoContext(ctx) + resp, err := handler(ctx, req) + if isResourceAPI(req.Spec().Procedure) { + code, _, _, _ := gerrors.ExtractZITADELError(err) + ctx = ainfo.ActivityInfoFromContext(ctx).SetGRPCStatus(code).IntoContext(ctx) + activity.TriggerGRPCWithContext(ctx, activity.ResourceAPI) + } + return resp, err + } + } +} + +var resourcePrefixes = []string{ + "/zitadel.management.v1.ManagementService/", + "/zitadel.admin.v1.AdminService/", + "/zitadel.user.v2.UserService/", + "/zitadel.settings.v2.SettingsService/", + "/zitadel.user.v2beta.UserService/", + "/zitadel.settings.v2beta.SettingsService/", + "/zitadel.auth.v1.AuthService/", +} + +func isResourceAPI(method string) bool { + return slices.ContainsFunc(resourcePrefixes, func(prefix string) bool { + return strings.HasPrefix(method, prefix) + }) +} + +func activityInfoFromGateway(ctx context.Context, headers http.Header) *ainfo.ActivityInfo { + info := ainfo.ActivityInfoFromContext(ctx) + path := headers.Get(activity.PathKey) + requestMethod := headers.Get(activity.RequestMethodKey) + return info.SetPath(path).SetRequestMethod(requestMethod) +} diff --git a/internal/api/grpc/server/connect_middleware/auth_interceptor.go b/internal/api/grpc/server/connect_middleware/auth_interceptor.go new file mode 100644 index 0000000000..9e500601d0 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/auth_interceptor.go @@ -0,0 +1,65 @@ +package connect_middleware + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func AuthorizationInterceptor(verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return authorize(ctx, req, handler, verifier, systemUserPermissions, authConfig) + } + } +} + +func authorize(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) (_ connect.AnyResponse, err error) { + authOpt, needsToken := verifier.CheckAuthMethod(req.Spec().Procedure) + if !needsToken { + return handler(ctx, req) + } + + authCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + authToken := req.Header().Get(http.Authorization) + if authToken == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("auth header missing")) + } + + orgID, orgDomain := orgIDAndDomainFromRequest(req) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, systemUserPermissions.RolePermissionMappings, authConfig.RolePermissionMappings, authOpt, req.Spec().Procedure) + if err != nil { + return nil, err + } + span.End() + return handler(ctxSetter(ctx), req) +} + +func orgIDAndDomainFromRequest(req connect.AnyRequest) (id, domain string) { + orgID := req.Header().Get(http.ZitadelOrgID) + oz, ok := req.Any().(OrganizationFromRequest) + if ok { + id = oz.OrganizationFromRequestConnect().ID + domain = oz.OrganizationFromRequestConnect().Domain + if id != "" || domain != "" { + return id, domain + } + } + return orgID, domain +} + +type Organization struct { + ID string + Domain string +} + +type OrganizationFromRequest interface { + OrganizationFromRequestConnect() *Organization +} diff --git a/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go new file mode 100644 index 0000000000..06e716c140 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go @@ -0,0 +1,318 @@ +package connect_middleware + +import ( + "context" + "errors" + "net/http" + "reflect" + "testing" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const anAPIRole = "AN_API_ROLE" + +type authzRepoMock struct{} + +func (v *authzRepoMock) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) { + return "", "", "", "", "", nil +} + +func (v *authzRepoMock) SearchMyMemberships(ctx context.Context, orgID string, _ bool) ([]*authz.Membership, error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Roles: []string{anAPIRole}, + }}, nil +} + +func (v *authzRepoMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { + return "", nil, nil +} + +func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { + return orgID, nil +} + +func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { + return "", "", nil +} + +var ( + accessTokenOK = authz.AccessTokenVerifierFunc(func(ctx context.Context, token string) (userID string, clientID string, agentID string, prefLan string, resourceOwner string, err error) { + return "user1", "", "", "", "org1", nil + }) + accessTokenNOK = authz.AccessTokenVerifierFunc(func(ctx context.Context, token string) (userID string, clientID string, agentID string, prefLan string, resourceOwner string, err error) { + return "", "", "", "", "", zerrors.ThrowUnauthenticated(nil, "TEST-fQHDI", "unauthenticaded") + }) + systemTokenNOK = authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return nil, "", errors.New("system token error") + }) +) + +type mockOrgFromRequest struct { + id string +} + +func (m *mockOrgFromRequest) OrganizationFromRequestConnect() *Organization { + return &Organization{ + ID: m.id, + Domain: "", + } +} + +func Test_authorize(t *testing.T) { + type args struct { + ctx context.Context + req connect.AnyRequest + handler func(t *testing.T) connect.UnaryFunc + verifier func() authz.APITokenVerifier + authConfig authz.Config + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "no token needed ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/no/token/needed"}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{}) + return verifier + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "auth header missing error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication"}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + nil, + true, + }, + }, + { + "unauthorized error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"wrong"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + nil, + true, + }, + }, + { + "authorized ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "authorized ok, org by request", + args{ + ctx: context.Background(), + req: &mockReq[mockOrgFromRequest]{ + Request: connect.Request[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, + procedure: "/need/authentication", + header: http.Header{"Authorization": []string{"Bearer token"}}, + }, + handler: emptyMockHandler(&connect.Response[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, authz.CtxData{ + UserID: "user1", + OrgID: "id", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + &connect.Response[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, + false, + }, + }, + { + "permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: anAPIRole, + Permissions: []string{"to.do.something.else"}, + }}, + }, + }, + res{ + nil, + true, + }, + }, + { + "permission ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: anAPIRole, + Permissions: []string{"to.do.something"}, + }}, + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "system token permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenNOK, authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, "systemuser", nil + })) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: "A_SYSTEM_ROLE", + Permissions: []string{"to.do.something.else"}, + }}, + }, + }, + res{ + nil, + true, + }, + }, + { + "system token permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "systemuser", + SystemMemberships: authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, + SystemUserPermissions: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeSystem, + Permissions: []string{"to.do.something"}, + }}, + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenNOK, authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, "systemuser", nil + })) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: "A_SYSTEM_ROLE", + Permissions: []string{"to.do.something"}, + }}, + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := authorize(tt.args.ctx, tt.args.req, tt.args.handler(t), tt.args.verifier(), tt.args.authConfig, tt.args.authConfig) + if (err != nil) != tt.res.wantErr { + t.Errorf("authorize() error = %v, wantErr %v", err, tt.res.wantErr) + return + } + if !reflect.DeepEqual(got, tt.res.want) { + t.Errorf("authorize() got = %v, want %v", got, tt.res.want) + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/cache_interceptor.go b/internal/api/grpc/server/connect_middleware/cache_interceptor.go new file mode 100644 index 0000000000..60ba0032f1 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/cache_interceptor.go @@ -0,0 +1,31 @@ +package connect_middleware + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + + _ "github.com/zitadel/zitadel/internal/statik" +) + +func NoCacheInterceptor() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + headers := map[string]string{ + "cache-control": "no-store", + "expires": time.Now().UTC().Format(http.TimeFormat), + "pragma": "no-cache", + } + resp, err := handler(ctx, req) + if err != nil { + return nil, err + } + for key, value := range headers { + resp.Header().Set(key, value) + } + return resp, err + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/call_interceptor.go b/internal/api/grpc/server/connect_middleware/call_interceptor.go new file mode 100644 index 0000000000..cc74e10f85 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/call_interceptor.go @@ -0,0 +1,18 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/call" +) + +func CallDurationHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx = call.WithTimestamp(ctx) + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/error_interceptor.go b/internal/api/grpc/server/connect_middleware/error_interceptor.go new file mode 100644 index 0000000000..9aef95bc6d --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/error_interceptor.go @@ -0,0 +1,23 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/grpc/gerrors" + _ "github.com/zitadel/zitadel/internal/statik" +) + +func ErrorHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return toConnectError(ctx, req, handler) + } + } +} + +func toConnectError(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc) (connect.AnyResponse, error) { + resp, err := handler(ctx, req) + return resp, gerrors.ZITADELToConnectError(err) // TODO ! +} diff --git a/internal/api/grpc/server/connect_middleware/error_interceptor_test.go b/internal/api/grpc/server/connect_middleware/error_interceptor_test.go new file mode 100644 index 0000000000..954f2fd58f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/error_interceptor_test.go @@ -0,0 +1,65 @@ +package connect_middleware + +import ( + "context" + "reflect" + "testing" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" +) + +func Test_toGRPCError(t *testing.T) { + type args struct { + ctx context.Context + req connect.AnyRequest + handler func(t *testing.T) connect.UnaryFunc + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "no error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{}, + handler: errorMockHandler(), + }, + res{ + nil, + true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toConnectError(tt.args.ctx, tt.args.req, tt.args.handler(t)) + if (err != nil) != tt.res.wantErr { + t.Errorf("toGRPCError() error = %v, wantErr %v", err, tt.res.wantErr) + return + } + if !reflect.DeepEqual(got, tt.res.want) { + t.Errorf("toGRPCError() got = %v, want %v", got, tt.res.want) + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor.go b/internal/api/grpc/server/connect_middleware/execution_interceptor.go new file mode 100644 index 0000000000..879496a33f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor.go @@ -0,0 +1,160 @@ +package connect_middleware + +import ( + "context" + "encoding/json" + + "connectrpc.com/connect" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/execution" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func ExecutionHandler(queries *query.Queries) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + requestTargets, responseTargets := execution.QueryExecutionTargetsForRequestAndResponse(ctx, queries, req.Spec().Procedure) + + // call targets otherwise return req + handledReq, err := executeTargetsForRequest(ctx, requestTargets, req.Spec().Procedure, req) + if err != nil { + return nil, err + } + + response, err := handler(ctx, handledReq) + if err != nil { + return nil, err + } + + return executeTargetsForResponse(ctx, responseTargets, req.Spec().Procedure, handledReq, response) + } + } +} + +func executeTargetsForRequest(ctx context.Context, targets []execution.Target, fullMethod string, req connect.AnyRequest) (_ connect.AnyRequest, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // if no targets are found, return without any calls + if len(targets) == 0 { + return req, nil + } + + ctxData := authz.GetCtxData(ctx) + info := &ContextInfoRequest{ + FullMethod: fullMethod, + InstanceID: authz.GetInstance(ctx).InstanceID(), + ProjectID: ctxData.ProjectID, + OrgID: ctxData.OrgID, + UserID: ctxData.UserID, + Request: Message{req.Any().(proto.Message)}, + } + + _, err = execution.CallTargets(ctx, targets, info) + if err != nil { + return nil, err + } + return req, nil +} + +func executeTargetsForResponse(ctx context.Context, targets []execution.Target, fullMethod string, req connect.AnyRequest, resp connect.AnyResponse) (_ connect.AnyResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // if no targets are found, return without any calls + if len(targets) == 0 { + return resp, nil + } + + ctxData := authz.GetCtxData(ctx) + info := &ContextInfoResponse{ + FullMethod: fullMethod, + InstanceID: authz.GetInstance(ctx).InstanceID(), + ProjectID: ctxData.ProjectID, + OrgID: ctxData.OrgID, + UserID: ctxData.UserID, + Request: Message{req.Any().(proto.Message)}, + Response: Message{resp.Any().(proto.Message)}, + } + + _, err = execution.CallTargets(ctx, targets, info) + if err != nil { + return nil, err + } + return resp, nil +} + +var _ execution.ContextInfo = &ContextInfoRequest{} + +type ContextInfoRequest struct { + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` +} + +type Message struct { + proto.Message +} + +func (r *Message) MarshalJSON() ([]byte, error) { + data, err := protojson.Marshal(r.Message) + if err != nil { + return nil, err + } + return data, nil +} + +func (r *Message) UnmarshalJSON(data []byte) error { + return protojson.Unmarshal(data, r.Message) +} + +func (c *ContextInfoRequest) GetHTTPRequestBody() []byte { + data, err := json.Marshal(c) + if err != nil { + return nil + } + return data +} + +func (c *ContextInfoRequest) SetHTTPResponseBody(resp []byte) error { + return json.Unmarshal(resp, &c.Request) +} + +func (c *ContextInfoRequest) GetContent() interface{} { + return c.Request.Message +} + +var _ execution.ContextInfo = &ContextInfoResponse{} + +type ContextInfoResponse struct { + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` + Response Message `json:"response,omitempty"` +} + +func (c *ContextInfoResponse) GetHTTPRequestBody() []byte { + data, err := json.Marshal(c) + if err != nil { + return nil + } + return data +} + +func (c *ContextInfoResponse) SetHTTPResponseBody(resp []byte) error { + return json.Unmarshal(resp, &c.Response) +} + +func (c *ContextInfoResponse) GetContent() interface{} { + return c.Response.Message +} diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go new file mode 100644 index 0000000000..d910824f21 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go @@ -0,0 +1,815 @@ +package connect_middleware + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/execution" +) + +var _ execution.Target = &mockExecutionTarget{} + +type mockExecutionTarget struct { + InstanceID string + ExecutionID string + TargetID string + TargetType domain.TargetType + Endpoint string + Timeout time.Duration + InterruptOnError bool + SigningKey string +} + +func (e *mockExecutionTarget) SetEndpoint(endpoint string) { + e.Endpoint = endpoint +} +func (e *mockExecutionTarget) IsInterruptOnError() bool { + return e.InterruptOnError +} +func (e *mockExecutionTarget) GetEndpoint() string { + return e.Endpoint +} +func (e *mockExecutionTarget) GetTargetType() domain.TargetType { + return e.TargetType +} +func (e *mockExecutionTarget) GetTimeout() time.Duration { + return e.Timeout +} +func (e *mockExecutionTarget) GetTargetID() string { + return e.TargetID +} +func (e *mockExecutionTarget) GetExecutionID() string { + return e.ExecutionID +} +func (e *mockExecutionTarget) GetSigningKey() string { + return e.SigningKey +} + +func newMockContentRequest(content string) *connect.Request[structpb.Struct] { + return connect.NewRequest(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "content": { + Kind: &structpb.Value_StringValue{StringValue: content}, + }, + }, + }) +} + +func newMockContentResponse(content string) *connect.Response[structpb.Struct] { + return connect.NewResponse(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "content": { + Kind: &structpb.Value_StringValue{StringValue: content}, + }, + }, + }) +} + +func newMockContextInfoRequest(fullMethod, request string) *ContextInfoRequest { + return &ContextInfoRequest{ + FullMethod: fullMethod, + Request: Message{Message: newMockContentRequest(request).Msg}, + } +} + +func newMockContextInfoResponse(fullMethod, request, response string) *ContextInfoResponse { + return &ContextInfoResponse{ + FullMethod: fullMethod, + Request: Message{Message: newMockContentRequest(request).Msg}, + Response: Message{Message: newMockContentResponse(response).Msg}, + } +} + +func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { + type target struct { + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody connect.AnyResponse + } + type args struct { + ctx context.Context + + executionTargets []execution.Target + targets []target + fullMethod string + req connect.AnyRequest + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "target, executionTargets nil", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: nil, + req: newMockContentRequest("request"), + }, + res{ + want: newMockContentRequest("request"), + }, + }, + { + "target, executionTargets empty", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{}, + req: newMockContentRequest("request"), + }, + res{ + want: newMockContentRequest("request"), + }, + }, + { + "target, not reachable", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + }, + }, + targets: []target{}, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, error without interrupt", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "target, interruptOnError", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, wrong request", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + {reqBody: newMockContextInfoRequest("/service/method", "wrong")}, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content1"), + }, + }, + { + "target async, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeAsync, + Timeout: time.Second, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "target async, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeAsync, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "webhook, error", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + sleep: 0, + statusCode: http.StatusInternalServerError, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "webhook, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "webhook, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "with includes, interruptOnError", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target1", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target2", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target3", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "with includes, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target1", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target2", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target3", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 5 * time.Second, + statusCode: http.StatusBadRequest, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeFuncs := make([]func(), len(tt.args.targets)) + for i, target := range tt.args.targets { + url, closeF := testServerCall( + target.reqBody, + target.sleep, + target.statusCode, + target.respBody, + ) + + et := tt.args.executionTargets[i].(*mockExecutionTarget) + et.SetEndpoint(url) + closeFuncs[i] = closeF + } + + resp, err := executeTargetsForRequest( + tt.args.ctx, + tt.args.executionTargets, + tt.args.fullMethod, + tt.args.req, + ) + + if tt.res.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.EqualExportedValues(t, tt.res.want, resp) + + for _, closeF := range closeFuncs { + closeF() + } + }) + } +} + +func testServerCall( + reqBody interface{}, + sleep time.Duration, + statusCode int, + respBody connect.AnyResponse, +) (string, func()) { + handler := func(w http.ResponseWriter, r *http.Request) { + data, err := json.Marshal(reqBody) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + sentBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if !reflect.DeepEqual(data, sentBody) { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if statusCode != http.StatusOK { + http.Error(w, "error", statusCode) + return + } + + time.Sleep(sleep) + + w.Header().Set("Content-Type", "application/json") + resp, err := protojson.Marshal(respBody.Any().(proto.Message)) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if _, err := w.Write(resp); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + + return server.URL, server.Close +} + +func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { + type target struct { + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody connect.AnyResponse + } + type args struct { + ctx context.Context + + executionTargets []execution.Target + targets []target + fullMethod string + req connect.AnyRequest + resp connect.AnyResponse + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "target, executionTargets nil", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: nil, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response"), + }, + }, + { + "target, executionTargets empty", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{}, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response"), + }, + }, + { + "target, empty response", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse(""), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest(""), + resp: newMockContentResponse(""), + }, + res{ + wantErr: true, + }, + }, + { + "target, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "response./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoResponse("/service/method", "request", "response"), + respBody: newMockContentResponse("response1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response1"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeFuncs := make([]func(), len(tt.args.targets)) + for i, target := range tt.args.targets { + url, closeF := testServerCall( + target.reqBody, + target.sleep, + target.statusCode, + target.respBody, + ) + + et := tt.args.executionTargets[i].(*mockExecutionTarget) + et.SetEndpoint(url) + closeFuncs[i] = closeF + } + + resp, err := executeTargetsForResponse( + tt.args.ctx, + tt.args.executionTargets, + tt.args.fullMethod, + tt.args.req, + tt.args.resp, + ) + + if tt.res.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.EqualExportedValues(t, tt.res.want, resp) + + for _, closeF := range closeFuncs { + closeF() + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/instance_interceptor.go b/internal/api/grpc/server/connect_middleware/instance_interceptor.go new file mode 100644 index 0000000000..27f59313f8 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/instance_interceptor.go @@ -0,0 +1,107 @@ +package connect_middleware + +import ( + "context" + "errors" + "fmt" + "strings" + + "connectrpc.com/connect" + "github.com/zitadel/logging" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + zitadel_http "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" + object_v3 "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" +) + +func InstanceInterceptor(verifier authz.InstanceVerifier, externalDomain string, explicitInstanceIdServices ...string) connect.UnaryInterceptorFunc { + translator, err := i18n.NewZitadelTranslator(language.English) + logging.OnError(err).Panic("unable to get translator") + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return setInstance(ctx, req, handler, verifier, externalDomain, translator, explicitInstanceIdServices...) + } + } +} + +func setInstance(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, externalDomain string, translator *i18n.Translator, idFromRequestsServices ...string) (_ connect.AnyResponse, err error) { + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + for _, service := range idFromRequestsServices { + if !strings.HasPrefix(service, "/") { + service = "/" + service + } + if strings.HasPrefix(req.Spec().Procedure, service) { + withInstanceIDProperty, ok := req.Any().(interface { + GetInstanceId() string + }) + if !ok { + return handler(ctx, req) + } + return addInstanceByID(interceptorCtx, req, handler, verifier, translator, withInstanceIDProperty.GetInstanceId()) + } + } + explicitInstanceRequest, ok := req.Any().(interface { + GetInstance() *object_v3.Instance + }) + if ok { + instance := explicitInstanceRequest.GetInstance() + if id := instance.GetId(); id != "" { + return addInstanceByID(interceptorCtx, req, handler, verifier, translator, id) + } + if domain := instance.GetDomain(); domain != "" { + return addInstanceByDomain(interceptorCtx, req, handler, verifier, translator, domain) + } + } + return addInstanceByRequestedHost(interceptorCtx, req, handler, verifier, translator, externalDomain) +} + +func addInstanceByID(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, id string) (connect.AnyResponse, error) { + instance, err := verifier.InstanceByID(ctx, id) + if err != nil { + notFoundErr := new(zerrors.ZitadelError) + if errors.As(err, ¬FoundErr) { + notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using id %s: %w", id, notFoundErr)) + } + return handler(authz.WithInstance(ctx, instance), req) +} + +func addInstanceByDomain(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, domain string) (connect.AnyResponse, error) { + instance, err := verifier.InstanceByHost(ctx, domain, "") + if err != nil { + notFoundErr := new(zerrors.NotFoundError) + if errors.As(err, ¬FoundErr) { + notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using domain %s: %w", domain, notFoundErr)) + } + return handler(authz.WithInstance(ctx, instance), req) +} + +func addInstanceByRequestedHost(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, externalDomain string) (connect.AnyResponse, error) { + requestContext := zitadel_http.DomainContext(ctx) + if requestContext.InstanceHost == "" { + logging.WithFields("origin", requestContext.Origin(), "externalDomain", externalDomain).Error("unable to set instance") + return nil, connect.NewError(connect.CodeNotFound, errors.New("no instanceHost specified")) + } + instance, err := verifier.InstanceByHost(ctx, requestContext.InstanceHost, requestContext.PublicHost) + if err != nil { + origin := zitadel_http.DomainContext(ctx) + logging.WithFields("origin", requestContext.Origin(), "externalDomain", externalDomain).WithError(err).Error("unable to set instance") + zErr := new(zerrors.ZitadelError) + if errors.As(err, &zErr) { + zErr.SetMessage(translator.LocalizeFromCtx(ctx, zErr.GetMessage(), nil)) + zErr.Parent = err + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, externalDomain, zErr.Error())) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s)", origin, externalDomain)) + } + return handler(authz.WithInstance(ctx, instance), req) +} diff --git a/internal/api/grpc/server/connect_middleware/limits_interceptor.go b/internal/api/grpc/server/connect_middleware/limits_interceptor.go new file mode 100644 index 0000000000..abf7e5f0aa --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/limits_interceptor.go @@ -0,0 +1,34 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func LimitsInterceptor(ignoreService ...string) connect.UnaryInterceptorFunc { + for idx, service := range ignoreService { + if !strings.HasPrefix(service, "/") { + ignoreService[idx] = "/" + service + } + } + + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + for _, service := range ignoreService { + if strings.HasPrefix(req.Spec().Procedure, service) { + return handler(ctx, req) + } + } + instance := authz.GetInstance(ctx) + if block := instance.Block(); block != nil && *block { + return nil, zerrors.ThrowResourceExhausted(nil, "LIMITS-molsj", "Errors.Limits.Instance.Blocked") + } + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/metrics_interceptor.go b/internal/api/grpc/server/connect_middleware/metrics_interceptor.go new file mode 100644 index 0000000000..552fa5658d --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/metrics_interceptor.go @@ -0,0 +1,96 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/zitadel/logging" + "go.opentelemetry.io/otel/attribute" + "google.golang.org/grpc/codes" + + _ "github.com/zitadel/zitadel/internal/statik" + "github.com/zitadel/zitadel/internal/telemetry/metrics" +) + +const ( + GrpcMethod = "grpc_method" + ReturnCode = "return_code" + GrpcRequestCounter = "grpc.server.request_counter" + GrpcRequestCounterDescription = "Grpc request counter" + TotalGrpcRequestCounter = "grpc.server.total_request_counter" + TotalGrpcRequestCounterDescription = "Total grpc request counter" + GrpcStatusCodeCounter = "grpc.server.grpc_status_code" + GrpcStatusCodeCounterDescription = "Grpc status code counter" +) + +func MetricsHandler(metricTypes []metrics.MetricType, ignoredMethodSuffixes ...string) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return RegisterMetrics(ctx, req, handler, metricTypes, ignoredMethodSuffixes...) + } + } +} + +func RegisterMetrics(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, metricTypes []metrics.MetricType, ignoredMethodSuffixes ...string) (_ connect.AnyResponse, err error) { + if len(metricTypes) == 0 { + return handler(ctx, req) + } + + for _, ignore := range ignoredMethodSuffixes { + if strings.HasSuffix(req.Spec().Procedure, ignore) { + return handler(ctx, req) + } + } + + resp, err := handler(ctx, req) + if containsMetricsMethod(metrics.MetricTypeRequestCount, metricTypes) { + RegisterGrpcRequestCounter(ctx, req.Spec().Procedure) + } + if containsMetricsMethod(metrics.MetricTypeTotalCount, metricTypes) { + RegisterGrpcTotalRequestCounter(ctx) + } + if containsMetricsMethod(metrics.MetricTypeStatusCode, metricTypes) { + RegisterGrpcRequestCodeCounter(ctx, req.Spec().Procedure, err) + } + return resp, err +} + +func RegisterGrpcRequestCounter(ctx context.Context, path string) { + var labels = map[string]attribute.Value{ + GrpcMethod: attribute.StringValue(path), + } + err := metrics.RegisterCounter(GrpcRequestCounter, GrpcRequestCounterDescription) + logging.OnError(err).Warn("failed to register grpc request counter") + err = metrics.AddCount(ctx, GrpcRequestCounter, 1, labels) + logging.OnError(err).Warn("failed to add grpc request count") +} + +func RegisterGrpcTotalRequestCounter(ctx context.Context) { + err := metrics.RegisterCounter(TotalGrpcRequestCounter, TotalGrpcRequestCounterDescription) + logging.OnError(err).Warn("failed to register total grpc request counter") + err = metrics.AddCount(ctx, TotalGrpcRequestCounter, 1, nil) + logging.OnError(err).Warn("failed to add total grpc request count") +} + +func RegisterGrpcRequestCodeCounter(ctx context.Context, path string, err error) { + statusCode := connect.CodeOf(err) + var labels = map[string]attribute.Value{ + GrpcMethod: attribute.StringValue(path), + ReturnCode: attribute.IntValue(runtime.HTTPStatusFromCode(codes.Code(statusCode))), + } + err = metrics.RegisterCounter(GrpcStatusCodeCounter, GrpcStatusCodeCounterDescription) + logging.OnError(err).Warn("failed to register grpc status code counter") + err = metrics.AddCount(ctx, GrpcStatusCodeCounter, 1, labels) + logging.OnError(err).Warn("failed to add grpc status code count") +} + +func containsMetricsMethod(metricType metrics.MetricType, metricTypes []metrics.MetricType) bool { + for _, m := range metricTypes { + if m == metricType { + return true + } + } + return false +} diff --git a/internal/api/grpc/server/connect_middleware/mock_test.go b/internal/api/grpc/server/connect_middleware/mock_test.go new file mode 100644 index 0000000000..abd996b01f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/mock_test.go @@ -0,0 +1,50 @@ +package connect_middleware + +import ( + "context" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func emptyMockHandler(resp connect.AnyResponse, expectedCtxData authz.CtxData) func(*testing.T) connect.UnaryFunc { + return func(t *testing.T) connect.UnaryFunc { + return func(ctx context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) { + assert.Equal(t, expectedCtxData, authz.GetCtxData(ctx)) + return resp, nil + } + } +} + +func errorMockHandler() func(*testing.T) connect.UnaryFunc { + return func(t *testing.T) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return nil, zerrors.ThrowInternal(nil, "test", "error") + } + } +} + +type mockReq[t any] struct { + connect.Request[t] + + procedure string + header http.Header +} + +func (m *mockReq[T]) Spec() connect.Spec { + return connect.Spec{ + Procedure: m.procedure, + } +} + +func (m *mockReq[T]) Header() http.Header { + if m.header == nil { + m.header = make(http.Header) + } + return m.header +} diff --git a/internal/api/grpc/server/connect_middleware/quota_interceptor.go b/internal/api/grpc/server/connect_middleware/quota_interceptor.go new file mode 100644 index 0000000000..caa32511e4 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/quota_interceptor.go @@ -0,0 +1,53 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/logstore" + "github.com/zitadel/zitadel/internal/logstore/record" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func QuotaExhaustedInterceptor(svc *logstore.Service[*record.AccessLog], ignoreService ...string) connect.UnaryInterceptorFunc { + for idx, service := range ignoreService { + if !strings.HasPrefix(service, "/") { + ignoreService[idx] = "/" + service + } + } + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + if !svc.Enabled() { + return handler(ctx, req) + } + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + // The auth interceptor will ensure that only authorized or public requests are allowed. + // So if there's no authorization context, we don't need to check for limitation + // Also, we don't limit calls with system user tokens + ctxData := authz.GetCtxData(ctx) + if ctxData.IsZero() || ctxData.SystemMemberships != nil { + return handler(ctx, req) + } + + for _, service := range ignoreService { + if strings.HasPrefix(req.Spec().Procedure, service) { + return handler(ctx, req) + } + } + + instance := authz.GetInstance(ctx) + remaining := svc.Limit(interceptorCtx, instance.InstanceID()) + if remaining != nil && *remaining == 0 { + return nil, zerrors.ThrowResourceExhausted(nil, "QUOTA-vjAy8", "Quota.Access.Exhausted") + } + span.End() + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/service_interceptor.go b/internal/api/grpc/server/connect_middleware/service_interceptor.go new file mode 100644 index 0000000000..c5cf798ce5 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/service_interceptor.go @@ -0,0 +1,45 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/service" + _ "github.com/zitadel/zitadel/internal/statik" +) + +const ( + unknown = "UNKNOWN" +) + +func ServiceHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + serviceName, _ := serviceAndMethod(req.Spec().Procedure) + if serviceName != unknown { + return handler(ctx, req) + } + ctx = service.WithService(ctx, serviceName) + return handler(ctx, req) + } + } +} + +// serviceAndMethod returns the service and method from a procedure. +func serviceAndMethod(procedure string) (string, string) { + procedure = strings.TrimPrefix(procedure, "/") + serviceName, method := unknown, unknown + if strings.Contains(procedure, "/") { + long := strings.Split(procedure, "/")[0] + if strings.Contains(long, ".") { + split := strings.Split(long, ".") + serviceName = split[len(split)-1] + } + } + if strings.Contains(procedure, "/") { + method = strings.Split(procedure, "/")[1] + } + return serviceName, method +} diff --git a/internal/api/grpc/server/connect_middleware/translation_interceptor.go b/internal/api/grpc/server/connect_middleware/translation_interceptor.go new file mode 100644 index 0000000000..f01b1c85ab --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/translation_interceptor.go @@ -0,0 +1,48 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/i18n" + _ "github.com/zitadel/zitadel/internal/statik" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func TranslationHandler() connect.UnaryInterceptorFunc { + + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + resp, err := handler(ctx, req) + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err != nil { + translator, translatorError := getTranslator(ctx) + if translatorError != nil { + return resp, err + } + return resp, translateError(ctx, err, translator) + } + if loc, ok := resp.Any().(localizers); ok { + translator, translatorError := getTranslator(ctx) + if translatorError != nil { + return resp, err + } + translateFields(ctx, loc, translator) + } + return resp, nil + } + } +} + +func getTranslator(ctx context.Context) (*i18n.Translator, error) { + translator, err := i18n.NewZitadelTranslator(authz.GetInstance(ctx).DefaultLanguage()) + if err != nil { + logging.New().WithError(err).Error("could not load translator") + } + return translator, err +} diff --git a/internal/api/grpc/server/connect_middleware/translator.go b/internal/api/grpc/server/connect_middleware/translator.go new file mode 100644 index 0000000000..6d61b1d772 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/translator.go @@ -0,0 +1,37 @@ +package connect_middleware + +import ( + "context" + "errors" + + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type localizers interface { + Localizers() []Localizer +} +type Localizer interface { + LocalizationKey() string + SetLocalizedMessage(string) +} + +func translateFields(ctx context.Context, object localizers, translator *i18n.Translator) { + if translator == nil || object == nil { + return + } + for _, field := range object.Localizers() { + field.SetLocalizedMessage(translator.LocalizeFromCtx(ctx, field.LocalizationKey(), nil)) + } +} + +func translateError(ctx context.Context, err error, translator *i18n.Translator) error { + if translator == nil || err == nil { + return err + } + caosErr := new(zerrors.ZitadelError) + if errors.As(err, &caosErr) { + caosErr.SetMessage(translator.LocalizeFromCtx(ctx, caosErr.GetMessage(), nil)) + } + return err +} diff --git a/internal/api/grpc/server/connect_middleware/validation_interceptor.go b/internal/api/grpc/server/connect_middleware/validation_interceptor.go new file mode 100644 index 0000000000..8441886114 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/validation_interceptor.go @@ -0,0 +1,36 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + // import to make sure go.mod does not lose it + // because dependency is only needed for generated code + _ "github.com/envoyproxy/protoc-gen-validate/validate" +) + +func ValidationHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return validate(ctx, req, handler) + } + } +} + +// validator interface needed for github.com/envoyproxy/protoc-gen-validate +// (it does not expose an interface itself) +type validator interface { + Validate() error +} + +func validate(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc) (connect.AnyResponse, error) { + validate, ok := req.Any().(validator) + if !ok { + return handler(ctx, req) + } + err := validate.Validate() + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return handler(ctx, req) +} diff --git a/internal/api/grpc/server/gateway.go b/internal/api/grpc/server/gateway.go index ca7579ee89..b20819b850 100644 --- a/internal/api/grpc/server/gateway.go +++ b/internal/api/grpc/server/gateway.go @@ -171,7 +171,7 @@ func CreateGateway( }, nil } -func RegisterGateway(ctx context.Context, gateway *Gateway, server Server) error { +func RegisterGateway(ctx context.Context, gateway *Gateway, server WithGateway) error { err := server.RegisterGateway()(ctx, gateway.mux, gateway.connection) if err != nil { return fmt.Errorf("failed to register grpc gateway: %w", err) diff --git a/internal/api/grpc/server/server.go b/internal/api/grpc/server/server.go index b686d3add9..0c02087c89 100644 --- a/internal/api/grpc/server/server.go +++ b/internal/api/grpc/server/server.go @@ -2,11 +2,14 @@ package server import ( "crypto/tls" + "net/http" + "connectrpc.com/connect" grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" "google.golang.org/grpc" "google.golang.org/grpc/credentials" healthpb "google.golang.org/grpc/health/grpc_health_v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" grpc_api "github.com/zitadel/zitadel/internal/api/grpc" @@ -19,21 +22,36 @@ import ( ) type Server interface { - RegisterServer(*grpc.Server) - RegisterGateway() RegisterGatewayFunc AppName() string MethodPrefix() string AuthMethods() authz.MethodMapping } +type GrpcServer interface { + Server + RegisterServer(*grpc.Server) +} + +type WithGateway interface { + Server + RegisterGateway() RegisterGatewayFunc +} + // WithGatewayPrefix extends the server interface with a prefix for the grpc gateway // // it's used for the System, Admin, Mgmt and Auth API type WithGatewayPrefix interface { - Server + GrpcServer + WithGateway GatewayPathPrefix() string } +type ConnectServer interface { + Server + RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) + FileDescriptor() protoreflect.FileDescriptor +} + func CreateServer( verifier authz.APITokenVerifier, systemAuthz authz.Config, diff --git a/internal/api/grpc/session/v2/query.go b/internal/api/grpc/session/v2/query.go index 73303dd9e8..78d8623ee7 100644 --- a/internal/api/grpc/session/v2/query.go +++ b/internal/api/grpc/session/v2/query.go @@ -4,6 +4,7 @@ import ( "context" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -26,18 +27,18 @@ var ( } ) -func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) +func (s *Server) GetSession(ctx context.Context, req *connect.Request[session.GetSessionRequest]) (*connect.Response[session.GetSessionResponse], error) { + res, err := s.query.SessionByID(ctx, true, req.Msg.GetSessionId(), req.Msg.GetSessionToken(), s.checkPermission) if err != nil { return nil, err } - return &session.GetSessionResponse{ + return connect.NewResponse(&session.GetSessionResponse{ Session: sessionToPb(res), - }, nil + }), nil } -func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { - queries, err := listSessionsRequestToQuery(ctx, req) +func (s *Server) ListSessions(ctx context.Context, req *connect.Request[session.ListSessionsRequest]) (*connect.Response[session.ListSessionsResponse], error) { + queries, err := listSessionsRequestToQuery(ctx, req.Msg) if err != nil { return nil, err } @@ -45,10 +46,10 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ if err != nil { return nil, err } - return &session.ListSessionsResponse{ + return connect.NewResponse(&session.ListSessionsResponse{ Details: object.ToListDetails(sessions.SearchResponse), Sessions: sessionsToPb(sessions.Sessions), - }, nil + }), nil } func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) { diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index ee534cb26c..8f06cb3fb0 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -1,7 +1,10 @@ package session import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2/sessionconnect" ) -var _ session.SessionServiceServer = (*Server)(nil) +var _ sessionconnect.SessionServiceHandler = (*Server)(nil) type Server struct { - session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - session.RegisterSessionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return sessionconnect.NewSessionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return session.File_zitadel_session_v2_session_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 08f19368ef..94f686a72c 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" @@ -17,12 +18,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { - checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) +func (s *Server) CreateSession(ctx context.Context, req *connect.Request[session.CreateSessionRequest]) (*connect.Response[session.CreateSessionResponse], error) { + checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } @@ -32,43 +33,43 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe return nil, err } - return &session.CreateSessionResponse{ + return connect.NewResponse(&session.CreateSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionId: set.ID, SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { - checks, err := s.setSessionRequestToCommand(ctx, req) +func (s *Server) SetSession(ctx context.Context, req *connect.Request[session.SetSessionRequest]) (*connect.Response[session.SetSessionResponse], error) { + checks, err := s.setSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } - set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration()) + set, err := s.command.UpdateSession(ctx, req.Msg.GetSessionId(), cmds, req.Msg.GetMetadata(), req.Msg.GetLifetime().AsDuration()) if err != nil { return nil, err } - return &session.SetSessionResponse{ + return connect.NewResponse(&session.SetSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { - details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) +func (s *Server) DeleteSession(ctx context.Context, req *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + details, err := s.command.TerminateSession(ctx, req.Msg.GetSessionId(), req.Msg.GetSessionToken()) if err != nil { return nil, err } - return &session.DeleteSessionResponse{ + return connect.NewResponse(&session.DeleteSessionResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) { diff --git a/internal/api/grpc/session/v2beta/server.go b/internal/api/grpc/session/v2beta/server.go index cf0d0c27f0..e659b406eb 100644 --- a/internal/api/grpc/session/v2beta/server.go +++ b/internal/api/grpc/session/v2beta/server.go @@ -1,7 +1,10 @@ package session import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2beta/sessionconnect" ) -var _ session.SessionServiceServer = (*Server)(nil) +var _ sessionconnect.SessionServiceHandler = (*Server)(nil) type Server struct { - session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - session.RegisterSessionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return sessionconnect.NewSessionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return session.File_zitadel_session_v2beta_session_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/session/v2beta/session.go b/internal/api/grpc/session/v2beta/session.go index 3b36b8ba83..459cf77f05 100644 --- a/internal/api/grpc/session/v2beta/session.go +++ b/internal/api/grpc/session/v2beta/session.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" @@ -31,18 +32,18 @@ var ( } ) -func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) +func (s *Server) GetSession(ctx context.Context, req *connect.Request[session.GetSessionRequest]) (*connect.Response[session.GetSessionResponse], error) { + res, err := s.query.SessionByID(ctx, true, req.Msg.GetSessionId(), req.Msg.GetSessionToken(), s.checkPermission) if err != nil { return nil, err } - return &session.GetSessionResponse{ + return connect.NewResponse(&session.GetSessionResponse{ Session: sessionToPb(res), - }, nil + }), nil } -func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { - queries, err := listSessionsRequestToQuery(ctx, req) +func (s *Server) ListSessions(ctx context.Context, req *connect.Request[session.ListSessionsRequest]) (*connect.Response[session.ListSessionsResponse], error) { + queries, err := listSessionsRequestToQuery(ctx, req.Msg) if err != nil { return nil, err } @@ -50,18 +51,18 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ if err != nil { return nil, err } - return &session.ListSessionsResponse{ + return connect.NewResponse(&session.ListSessionsResponse{ Details: object.ToListDetails(sessions.SearchResponse), Sessions: sessionsToPb(sessions.Sessions), - }, nil + }), nil } -func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { - checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) +func (s *Server) CreateSession(ctx context.Context, req *connect.Request[session.CreateSessionRequest]) (*connect.Response[session.CreateSessionResponse], error) { + checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } @@ -71,43 +72,43 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe return nil, err } - return &session.CreateSessionResponse{ + return connect.NewResponse(&session.CreateSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionId: set.ID, SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { - checks, err := s.setSessionRequestToCommand(ctx, req) +func (s *Server) SetSession(ctx context.Context, req *connect.Request[session.SetSessionRequest]) (*connect.Response[session.SetSessionResponse], error) { + checks, err := s.setSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } - set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration()) + set, err := s.command.UpdateSession(ctx, req.Msg.GetSessionId(), cmds, req.Msg.GetMetadata(), req.Msg.GetLifetime().AsDuration()) if err != nil { return nil, err } - return &session.SetSessionResponse{ + return connect.NewResponse(&session.SetSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { - details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) +func (s *Server) DeleteSession(ctx context.Context, req *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + details, err := s.command.TerminateSession(ctx, req.Msg.GetSessionId(), req.Msg.GetSessionToken()) if err != nil { return nil, err } - return &session.DeleteSessionResponse{ + return connect.NewResponse(&session.DeleteSessionResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func sessionsToPb(sessions []*query.Session) []*session.Session { diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go index b8994ccb87..d522424040 100644 --- a/internal/api/grpc/settings/v2/query.go +++ b/internal/api/grpc/settings/v2/query.go @@ -3,6 +3,7 @@ package settings import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -14,12 +15,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLoginSettings(ctx context.Context, req *connect.Request[settings.GetLoginSettingsRequest]) (*connect.Response[settings.GetLoginSettingsResponse], error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLoginSettingsResponse{ + return connect.NewResponse(&settings.GetLoginSettingsResponse{ Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -27,15 +28,15 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, - }, nil + }), nil } -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *connect.Request[settings.GetPasswordComplexitySettingsRequest]) (*connect.Response[settings.GetPasswordComplexitySettingsResponse], error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordComplexitySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordComplexitySettingsResponse{ Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -43,15 +44,15 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *connect.Request[settings.GetPasswordExpirySettingsRequest]) (*connect.Response[settings.GetPasswordExpirySettingsResponse], error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordExpirySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordExpirySettingsResponse{ Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -59,15 +60,15 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetBrandingSettings(ctx context.Context, req *connect.Request[settings.GetBrandingSettingsRequest]) (*connect.Response[settings.GetBrandingSettingsResponse], error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetBrandingSettingsResponse{ + return connect.NewResponse(&settings.GetBrandingSettingsResponse{ Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -75,15 +76,15 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetDomainSettings(ctx context.Context, req *connect.Request[settings.GetDomainSettingsRequest]) (*connect.Response[settings.GetDomainSettingsResponse], error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetDomainSettingsResponse{ + return connect.NewResponse(&settings.GetDomainSettingsResponse{ Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -91,15 +92,15 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *connect.Request[settings.GetLegalAndSupportSettingsRequest]) (*connect.Response[settings.GetLegalAndSupportSettingsResponse], error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLegalAndSupportSettingsResponse{ + return connect.NewResponse(&settings.GetLegalAndSupportSettingsResponse{ Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -107,15 +108,15 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) +func (s *Server) GetLockoutSettings(ctx context.Context, req *connect.Request[settings.GetLockoutSettingsRequest]) (*connect.Response[settings.GetLockoutSettingsResponse], error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx())) if err != nil { return nil, err } - return &settings.GetLockoutSettingsResponse{ + return connect.NewResponse(&settings.GetLockoutSettingsResponse{ Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -123,24 +124,24 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - queries, err := activeIdentityProvidersToQuery(req) +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *connect.Request[settings.GetActiveIdentityProvidersRequest]) (*connect.Response[settings.GetActiveIdentityProvidersResponse], error) { + queries, err := activeIdentityProvidersToQuery(req.Msg) if err != nil { return nil, err } - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) if err != nil { return nil, err } - return &settings.GetActiveIdentityProvidersResponse{ + return connect.NewResponse(&settings.GetActiveIdentityProvidersResponse{ Details: object.ToListDetails(links.SearchResponse), IdentityProviders: identityProvidersToPb(links.Links), - }, nil + }), nil } func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { @@ -180,30 +181,30 @@ func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequ return q, nil } -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { +func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) { instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ + return connect.NewResponse(&settings.GetGeneralSettingsResponse{ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), DefaultOrgId: instance.DefaultOrganisationID(), DefaultLanguage: instance.DefaultLanguage().String(), - }, nil + }), nil } -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { +func (s *Server) GetSecuritySettings(ctx context.Context, req *connect.Request[settings.GetSecuritySettingsRequest]) (*connect.Response[settings.GetSecuritySettingsResponse], error) { policy, err := s.query.SecurityPolicy(ctx) if err != nil { return nil, err } - return &settings.GetSecuritySettingsResponse{ + return connect.NewResponse(&settings.GetSecuritySettingsResponse{ Settings: securityPolicyToSettingsPb(policy), - }, nil + }), nil } -func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (*settings.GetHostedLoginTranslationResponse, error) { - translation, err := s.query.GetHostedLoginTranslation(ctx, req) +func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *connect.Request[settings.GetHostedLoginTranslationRequest]) (*connect.Response[settings.GetHostedLoginTranslationResponse], error) { + translation, err := s.query.GetHostedLoginTranslation(ctx, req.Msg) if err != nil { return nil, err } - return translation, nil + return connect.NewResponse(translation), nil } diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go index 9cae50824f..bfaec17fc2 100644 --- a/internal/api/grpc/settings/v2/server.go +++ b/internal/api/grpc/settings/v2/server.go @@ -2,8 +2,10 @@ package settings import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/api/authz" @@ -11,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2/settingsconnect" ) -var _ settings.SettingsServiceServer = (*Server)(nil) +var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - settings.UnimplementedSettingsServiceServer command *command.Commands query *query.Queries assetsAPIDomain func(context.Context) string @@ -35,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - settings.RegisterSettingsServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return settingsconnect.NewSettingsServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return settings.File_zitadel_settings_v2_settings_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 09ee6b27c8..c7db200211 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -3,25 +3,27 @@ package settings import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { - details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) +func (s *Server) SetSecuritySettings(ctx context.Context, req *connect.Request[settings.SetSecuritySettingsRequest]) (*connect.Response[settings.SetSecuritySettingsResponse], error) { + details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req.Msg)) if err != nil { return nil, err } - return &settings.SetSecuritySettingsResponse{ + return connect.NewResponse(&settings.SetSecuritySettingsResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (*settings.SetHostedLoginTranslationResponse, error) { - res, err := s.command.SetHostedLoginTranslation(ctx, req) +func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *connect.Request[settings.SetHostedLoginTranslationRequest]) (*connect.Response[settings.SetHostedLoginTranslationResponse], error) { + res, err := s.command.SetHostedLoginTranslation(ctx, req.Msg) if err != nil { return nil, err } - return res, nil + return connect.NewResponse(res), nil } diff --git a/internal/api/grpc/settings/v2beta/server.go b/internal/api/grpc/settings/v2beta/server.go index 24c8f7774a..a8200a7216 100644 --- a/internal/api/grpc/settings/v2beta/server.go +++ b/internal/api/grpc/settings/v2beta/server.go @@ -2,8 +2,10 @@ package settings import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/api/authz" @@ -11,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta/settingsconnect" ) -var _ settings.SettingsServiceServer = (*Server)(nil) +var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - settings.UnimplementedSettingsServiceServer command *command.Commands query *query.Queries assetsAPIDomain func(context.Context) string @@ -35,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - settings.RegisterSettingsServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return settingsconnect.NewSettingsServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return settings.File_zitadel_settings_v2beta_settings_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/settings/v2beta/settings.go b/internal/api/grpc/settings/v2beta/settings.go index 6193f129ba..53d2c37c32 100644 --- a/internal/api/grpc/settings/v2beta/settings.go +++ b/internal/api/grpc/settings/v2beta/settings.go @@ -3,6 +3,7 @@ package settings import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -14,12 +15,12 @@ import ( settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLoginSettings(ctx context.Context, req *connect.Request[settings.GetLoginSettingsRequest]) (*connect.Response[settings.GetLoginSettingsResponse], error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLoginSettingsResponse{ + return connect.NewResponse(&settings.GetLoginSettingsResponse{ Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -27,15 +28,15 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, - }, nil + }), nil } -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *connect.Request[settings.GetPasswordComplexitySettingsRequest]) (*connect.Response[settings.GetPasswordComplexitySettingsResponse], error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordComplexitySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordComplexitySettingsResponse{ Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -43,15 +44,15 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *connect.Request[settings.GetPasswordExpirySettingsRequest]) (*connect.Response[settings.GetPasswordExpirySettingsResponse], error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordExpirySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordExpirySettingsResponse{ Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -59,15 +60,15 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetBrandingSettings(ctx context.Context, req *connect.Request[settings.GetBrandingSettingsRequest]) (*connect.Response[settings.GetBrandingSettingsResponse], error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetBrandingSettingsResponse{ + return connect.NewResponse(&settings.GetBrandingSettingsResponse{ Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -75,15 +76,15 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetDomainSettings(ctx context.Context, req *connect.Request[settings.GetDomainSettingsRequest]) (*connect.Response[settings.GetDomainSettingsResponse], error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetDomainSettingsResponse{ + return connect.NewResponse(&settings.GetDomainSettingsResponse{ Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -91,15 +92,15 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *connect.Request[settings.GetLegalAndSupportSettingsRequest]) (*connect.Response[settings.GetLegalAndSupportSettingsResponse], error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLegalAndSupportSettingsResponse{ + return connect.NewResponse(&settings.GetLegalAndSupportSettingsResponse{ Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -107,15 +108,15 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) +func (s *Server) GetLockoutSettings(ctx context.Context, req *connect.Request[settings.GetLockoutSettingsRequest]) (*connect.Response[settings.GetLockoutSettingsResponse], error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx())) if err != nil { return nil, err } - return &settings.GetLockoutSettingsResponse{ + return connect.NewResponse(&settings.GetLockoutSettingsResponse{ Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -123,46 +124,46 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *connect.Request[settings.GetActiveIdentityProvidersRequest]) (*connect.Response[settings.GetActiveIdentityProvidersResponse], error) { + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) if err != nil { return nil, err } - return &settings.GetActiveIdentityProvidersResponse{ + return connect.NewResponse(&settings.GetActiveIdentityProvidersResponse{ Details: object.ToListDetails(links.SearchResponse), IdentityProviders: identityProvidersToPb(links.Links), - }, nil + }), nil } -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { +func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) { instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ + return connect.NewResponse(&settings.GetGeneralSettingsResponse{ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), DefaultOrgId: instance.DefaultOrganisationID(), DefaultLanguage: instance.DefaultLanguage().String(), - }, nil + }), nil } -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { +func (s *Server) GetSecuritySettings(ctx context.Context, req *connect.Request[settings.GetSecuritySettingsRequest]) (*connect.Response[settings.GetSecuritySettingsResponse], error) { policy, err := s.query.SecurityPolicy(ctx) if err != nil { return nil, err } - return &settings.GetSecuritySettingsResponse{ + return connect.NewResponse(&settings.GetSecuritySettingsResponse{ Settings: securityPolicyToSettingsPb(policy), - }, nil + }), nil } -func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { - details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) +func (s *Server) SetSecuritySettings(ctx context.Context, req *connect.Request[settings.SetSecuritySettingsRequest]) (*connect.Response[settings.SetSecuritySettingsResponse], error) { + details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req.Msg)) if err != nil { return nil, err } - return &settings.SetSecuritySettingsResponse{ + return connect.NewResponse(&settings.SetSecuritySettingsResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 4b247ef10f..df68e58c7d 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { +func (s *Server) SetEmail(ctx context.Context, req *connect.Request[user.SetEmailRequest]) (resp *connect.Response[user.SetEmailResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetEmailRequest_SendCode: - email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SetEmailRequest_ReturnCode: - email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmailReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) case *user.SetEmailRequest_IsVerified: - email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail()) + email, err = s.command.ChangeUserEmailVerified(ctx, req.Msg.GetUserId(), req.Msg.GetEmail()) case nil: - email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmail(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v) } @@ -30,26 +31,26 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp return nil, err } - return &user.SetEmailResponse{ + return connect.NewResponse(&user.SetEmailResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) { +func (s *Server) ResendEmailCode(ctx context.Context, req *connect.Request[user.ResendEmailCodeRequest]) (resp *connect.Response[user.ResendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendEmailCodeRequest_SendCode: - email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.ResendEmailCodeRequest_ReturnCode: - email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v) } @@ -57,26 +58,26 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR return nil, err } - return &user.ResendEmailCodeResponse{ + return connect.NewResponse(&user.ResendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeRequest) (resp *user.SendEmailCodeResponse, err error) { +func (s *Server) SendEmailCode(ctx context.Context, req *connect.Request[user.SendEmailCodeRequest]) (resp *connect.Response[user.SendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SendEmailCodeRequest_SendCode: - email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SendEmailCodeRequest_ReturnCode: - email, err = s.command.SendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.SendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.SendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.SendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method SendEmailCode not implemented", v) } @@ -84,30 +85,30 @@ func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeReque return nil, err } - return &user.SendEmailCodeResponse{ + return connect.NewResponse(&user.SendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { +func (s *Server) VerifyEmail(ctx context.Context, req *connect.Request[user.VerifyEmailRequest]) (*connect.Response[user.VerifyEmailResponse], error) { details, err := s.command.VerifyUserEmail(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyEmailResponse{ + return connect.NewResponse(&user.VerifyEmailResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/human.go b/internal/api/grpc/user/v2/human.go index d8a0891396..06414d12cb 100644 --- a/internal/api/grpc/user/v2/human.go +++ b/internal/api/grpc/user/v2/human.go @@ -4,6 +4,7 @@ import ( "context" "io" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/timestamppb" @@ -14,7 +15,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*user.CreateUserResponse, error) { +func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*connect.Response[user.CreateUserResponse], error) { addHumanPb := &user.AddHumanUserRequest{ Username: userName, UserId: userId, @@ -52,15 +53,15 @@ func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUs ); err != nil { return nil, err } - return &user.CreateUserResponse{ + return connect.NewResponse(&user.CreateUserResponse{ Id: newHuman.ID, CreationDate: timestamppb.New(newHuman.Details.EventDate), EmailCode: newHuman.EmailCode, PhoneCode: newHuman.PhoneCode, - }, nil + }), nil } -func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*user.UpdateUserResponse, error) { +func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*connect.Response[user.UpdateUserResponse], error) { cmd, err := updateHumanUserToCommand(userId, userName, humanPb) if err != nil { return nil, err @@ -68,11 +69,11 @@ func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUs if err = s.command.ChangeUserHuman(ctx, cmd, s.userCodeAlg); err != nil { return nil, err } - return &user.UpdateUserResponse{ + return connect.NewResponse(&user.UpdateUserResponse{ ChangeDate: timestamppb.New(cmd.Details.EventDate), EmailCode: cmd.EmailCode, PhoneCode: cmd.PhoneCode, - }, nil + }), nil } func updateHumanUserToCommand(userId string, userName *string, human *user.UpdateUserRequest_Human) (*command.ChangeHuman, error) { diff --git a/internal/api/grpc/user/v2/idp_link.go b/internal/api/grpc/user/v2/idp_link.go index bef40617cf..0b1e7ab998 100644 --- a/internal/api/grpc/user/v2/idp_link.go +++ b/internal/api/grpc/user/v2/idp_link.go @@ -3,6 +3,8 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" @@ -11,22 +13,22 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { - details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ - IDPID: req.GetIdpLink().GetIdpId(), - DisplayName: req.GetIdpLink().GetUserName(), - IDPExternalID: req.GetIdpLink().GetUserId(), +func (s *Server) AddIDPLink(ctx context.Context, req *connect.Request[user.AddIDPLinkRequest]) (_ *connect.Response[user.AddIDPLinkResponse], err error) { + details, err := s.command.AddUserIDPLink(ctx, req.Msg.GetUserId(), "", &command.AddLink{ + IDPID: req.Msg.GetIdpLink().GetIdpId(), + DisplayName: req.Msg.GetIdpLink().GetUserName(), + IDPExternalID: req.Msg.GetIdpLink().GetUserId(), }) if err != nil { return nil, err } - return &user.AddIDPLinkResponse{ + return connect.NewResponse(&user.AddIDPLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest) (_ *user.ListIDPLinksResponse, err error) { - queries, err := ListLinkedIDPsRequestToQuery(req) +func (s *Server) ListIDPLinks(ctx context.Context, req *connect.Request[user.ListIDPLinksRequest]) (_ *connect.Response[user.ListIDPLinksResponse], err error) { + queries, err := ListLinkedIDPsRequestToQuery(req.Msg) if err != nil { return nil, err } @@ -34,10 +36,10 @@ func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest if err != nil { return nil, err } - return &user.ListIDPLinksResponse{ + return connect.NewResponse(&user.ListIDPLinksResponse{ Result: IDPLinksToPb(res.Links), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func ListLinkedIDPsRequestToQuery(req *user.ListIDPLinksRequest) (*query.IDPUserLinksSearchQuery, error) { @@ -72,14 +74,14 @@ func IDPLinkToPb(link *query.IDPUserLink) *user.IDPLink { } } -func (s *Server) RemoveIDPLink(ctx context.Context, req *user.RemoveIDPLinkRequest) (*user.RemoveIDPLinkResponse, error) { - objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req)) +func (s *Server) RemoveIDPLink(ctx context.Context, req *connect.Request[user.RemoveIDPLinkRequest]) (*connect.Response[user.RemoveIDPLinkResponse], error) { + objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &user.RemoveIDPLinkResponse{ + return connect.NewResponse(&user.RemoveIDPLinkResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } func RemoveIDPLinkRequestToDomain(ctx context.Context, req *user.RemoveIDPLinkRequest) *domain.UserIDPLink { diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index fd65d61dfb..c26adba24d 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -6,6 +6,7 @@ import ( "errors" "time" + "connectrpc.com/connect" oidc_pkg "github.com/zitadel/oidc/v3/pkg/oidc" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -32,18 +33,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) { - switch t := req.GetContent().(type) { +func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.Request[user.StartIdentityProviderIntentRequest]) (_ *connect.Response[user.StartIdentityProviderIntentResponse], err error) { + switch t := req.Msg.GetContent().(type) { case *user.StartIdentityProviderIntentRequest_Urls: - return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls) + return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls) case *user.StartIdentityProviderIntentRequest_Ldap: - return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap) + return s.startLDAPIntent(ctx, req.Msg.GetIdpId(), t.Ldap) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderIntent not implemented", t) } } -func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) if err != nil { return nil, err @@ -58,12 +59,12 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re } switch a := auth.(type) { case *idp.RedirectAuth: - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, - }, nil + }), nil case *idp.FormAuth: - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_FormData{ FormData: &user.FormData{ @@ -71,12 +72,12 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re Fields: a.Fields, }, }, - }, nil + }), nil } return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } -func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil) if err != nil { return nil, err @@ -92,7 +93,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti if err != nil { return nil, err } - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_IdpIntent{ IdpIntent: &user.IDPIntent{ @@ -101,7 +102,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti UserId: userID, }, }, - }, nil + }), nil } func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) { @@ -150,12 +151,12 @@ func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string return externalUser, userID, session, nil } -func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { - intent, err := s.command.GetIntentWriteModel(ctx, req.GetIdpIntentId(), "") +func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *connect.Request[user.RetrieveIdentityProviderIntentRequest]) (_ *connect.Response[user.RetrieveIdentityProviderIntentResponse], err error) { + intent, err := s.command.GetIntentWriteModel(ctx, req.Msg.GetIdpIntentId(), "") if err != nil { return nil, err } - if err := s.checkIntentToken(req.GetIdpIntentToken(), intent.AggregateID); err != nil { + if err := s.checkIntentToken(req.Msg.GetIdpIntentToken(), intent.AggregateID); err != nil { return nil, err } if intent.State != domain.IDPIntentStateSucceeded { @@ -203,7 +204,7 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R } idpIntent.AddHumanUser = idpUserToAddHumanUser(idpUser, idpIntent.IdpInformation.IdpId) } - return idpIntent, nil + return connect.NewResponse(idpIntent), nil } type rawUserMapper struct { diff --git a/internal/api/grpc/user/v2/key.go b/internal/api/grpc/user/v2/key.go index 59dab44248..021f4be388 100644 --- a/internal/api/grpc/user/v2/key.go +++ b/internal/api/grpc/user/v2/key.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,16 +12,16 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddKey(ctx context.Context, req *user.AddKeyRequest) (*user.AddKeyResponse, error) { +func (s *Server) AddKey(ctx context.Context, req *connect.Request[user.AddKeyRequest]) (*connect.Response[user.AddKeyResponse], error) { newMachineKey := &command.MachineKey{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, - ExpirationDate: req.GetExpirationDate().AsTime(), + ExpirationDate: req.Msg.GetExpirationDate().AsTime(), Type: domain.AuthNKeyTypeJSON, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), } - newMachineKey.PublicKey = req.PublicKey + newMachineKey.PublicKey = req.Msg.GetPublicKey() pubkeySupplied := len(newMachineKey.PublicKey) > 0 details, err := s.command.AddUserMachineKey(ctx, newMachineKey) @@ -37,26 +38,26 @@ func (s *Server) AddKey(ctx context.Context, req *user.AddKeyRequest) (*user.Add return nil, err } } - return &user.AddKeyResponse{ + return connect.NewResponse(&user.AddKeyResponse{ KeyId: newMachineKey.KeyID, KeyContent: keyDetails, CreationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) RemoveKey(ctx context.Context, req *user.RemoveKeyRequest) (*user.RemoveKeyResponse, error) { +func (s *Server) RemoveKey(ctx context.Context, req *connect.Request[user.RemoveKeyRequest]) (*connect.Response[user.RemoveKeyResponse], error) { machineKey := &command.MachineKey{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), - KeyID: req.KeyId, + KeyID: req.Msg.GetKeyId(), } objectDetails, err := s.command.RemoveUserMachineKey(ctx, machineKey) if err != nil { return nil, err } - return &user.RemoveKeyResponse{ + return connect.NewResponse(&user.RemoveKeyResponse{ DeletionDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/key_query.go b/internal/api/grpc/user/v2/key_query.go index da4f47decf..e9466a791b 100644 --- a/internal/api/grpc/user/v2/key_query.go +++ b/internal/api/grpc/user/v2/key_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" @@ -12,13 +13,13 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user.ListKeysResponse, error) { - offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) +func (s *Server) ListKeys(ctx context.Context, req *connect.Request[user.ListKeysRequest]) (*connect.Response[user.ListKeysResponse], error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Msg.GetPagination()) if err != nil { return nil, err } - filters, err := keyFiltersToQueries(req.Filters) + filters, err := keyFiltersToQueries(req.Msg.GetFilters()) if err != nil { return nil, err } @@ -27,7 +28,7 @@ func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user Offset: offset, Limit: limit, Asc: asc, - SortingColumn: authnKeyFieldNameToSortingColumn(req.SortingColumn), + SortingColumn: authnKeyFieldNameToSortingColumn(req.Msg.SortingColumn), }, Queries: filters, } @@ -49,7 +50,7 @@ func (s *Server) ListKeys(ctx context.Context, req *user.ListKeysRequest) (*user ExpirationDate: timestamppb.New(key.Expiration), } } - return resp, nil + return connect.NewResponse(resp), nil } func keyFiltersToQueries(filters []*user.KeysSearchFilter) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/user/v2/machine.go b/internal/api/grpc/user/v2/machine.go index ad02b2289e..e5126b9019 100644 --- a/internal/api/grpc/user/v2/machine.go +++ b/internal/api/grpc/user/v2/machine.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,7 +12,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*user.CreateUserResponse, error) { +func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*connect.Response[user.CreateUserResponse], error) { cmd := &command.Machine{ Username: userName, Name: machinePb.Name, @@ -32,21 +33,21 @@ func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.Crea if err != nil { return nil, err } - return &user.CreateUserResponse{ + return connect.NewResponse(&user.CreateUserResponse{ Id: cmd.AggregateID, CreationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) updateUserTypeMachine(ctx context.Context, machinePb *user.UpdateUserRequest_Machine, userId string, userName *string) (*user.UpdateUserResponse, error) { +func (s *Server) updateUserTypeMachine(ctx context.Context, machinePb *user.UpdateUserRequest_Machine, userId string, userName *string) (*connect.Response[user.UpdateUserResponse], error) { cmd := updateMachineUserToCommand(userId, userName, machinePb) err := s.command.ChangeUserMachine(ctx, cmd) if err != nil { return nil, err } - return &user.UpdateUserResponse{ + return connect.NewResponse(&user.UpdateUserResponse{ ChangeDate: timestamppb.New(cmd.Details.EventDate), - }, nil + }), nil } func updateMachineUserToCommand(userId string, userName *string, machine *user.UpdateUserRequest_Machine) *command.ChangeMachine { diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go index fd76cf2b93..2f04f438dd 100644 --- a/internal/api/grpc/user/v2/otp.go +++ b/internal/api/grpc/user/v2/otp.go @@ -3,39 +3,41 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { - details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) AddOTPSMS(ctx context.Context, req *connect.Request[user.AddOTPSMSRequest]) (*connect.Response[user.AddOTPSMSResponse], error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPSMS(ctx context.Context, req *connect.Request[user.RemoveOTPSMSRequest]) (*connect.Response[user.RemoveOTPSMSResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } -func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { - details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) AddOTPEmail(ctx context.Context, req *connect.Request[user.AddOTPEmailRequest]) (*connect.Response[user.AddOTPEmailResponse], error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPEmail(ctx context.Context, req *connect.Request[user.RemoveOTPEmailRequest]) (*connect.Response[user.RemoveOTPEmailResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2/passkey.go b/internal/api/grpc/user/v2/passkey.go index 145c1e5716..90c6d72d13 100644 --- a/internal/api/grpc/user/v2/passkey.go +++ b/internal/api/grpc/user/v2/passkey.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" @@ -13,17 +14,17 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) { +func (s *Server) RegisterPasskey(ctx context.Context, req *connect.Request[user.RegisterPasskeyRequest]) (resp *connect.Response[user.RegisterPasskeyResponse], err error) { var ( - authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator()) + authenticator = passkeyAuthenticatorToDomain(req.Msg.GetAuthenticator()) ) - if code := req.GetCode(); code != nil { + if code := req.Msg.GetCode(); code != nil { return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), "", authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg), + s.command.RegisterUserPasskeyWithCode(ctx, req.Msg.GetUserId(), "", authenticator, code.Id, code.Code, req.Msg.GetDomain(), s.userCodeAlg), ) } return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskey(ctx, req.GetUserId(), "", req.GetDomain(), authenticator), + s.command.RegisterUserPasskey(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain(), authenticator), ) } @@ -51,86 +52,86 @@ func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails return object.DomainToDetailsPb(details.ObjectDetails), options, nil } -func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) { +func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterPasskeyResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterPasskeyResponse{ + return connect.NewResponse(&user.RegisterPasskeyResponse{ Details: objectDetails, PasskeyId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *connect.Request[user.VerifyPasskeyRegistrationRequest]) (*connect.Response[user.VerifyPasskeyRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal") } - objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), "", req.GetPasskeyName(), "", pkc) + objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetPasskeyName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyPasskeyRegistrationResponse{ + return connect.NewResponse(&user.VerifyPasskeyRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) { - switch medium := req.Medium.(type) { +func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *connect.Request[user.CreatePasskeyRegistrationLinkRequest]) (resp *connect.Response[user.CreatePasskeyRegistrationLinkResponse], err error) { + switch medium := req.Msg.Medium.(type) { case nil: return passkeyDetailsToPb( - s.command.AddUserPasskeyCode(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCode(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) case *user.CreatePasskeyRegistrationLinkRequest_SendLink: return passkeyDetailsToPb( - s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), + s.command.AddUserPasskeyCodeURLTemplate(ctx, req.Msg.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), ) case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode: return passkeyCodeDetailsToPb( - s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCodeReturn(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium) } } -func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details.ObjectDetails), Code: &user.PasskeyRegistrationCode{ Id: details.CodeID, Code: details.Code, }, - }, nil + }), nil } -func (s *Server) RemovePasskey(ctx context.Context, req *user.RemovePasskeyRequest) (*user.RemovePasskeyResponse, error) { - objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.GetUserId(), req.GetPasskeyId(), "") +func (s *Server) RemovePasskey(ctx context.Context, req *connect.Request[user.RemovePasskeyRequest]) (*connect.Response[user.RemovePasskeyResponse], error) { + objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.Msg.GetUserId(), req.Msg.GetPasskeyId(), "") if err != nil { return nil, err } - return &user.RemovePasskeyResponse{ + return connect.NewResponse(&user.RemovePasskeyResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest) (*user.ListPasskeysResponse, error) { +func (s *Server) ListPasskeys(ctx context.Context, req *connect.Request[user.ListPasskeysRequest]) (*connect.Response[user.ListPasskeysResponse], error) { query := new(query.UserAuthMethodSearchQueries) - err := query.AppendUserIDQuery(req.UserId) + err := query.AppendUserIDQuery(req.Msg.UserId) if err != nil { return nil, err } @@ -146,10 +147,10 @@ func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest if err != nil { return nil, err } - return &user.ListPasskeysResponse{ + return connect.NewResponse(&user.ListPasskeysResponse{ Details: object.ToListDetails(authMethods.SearchResponse), Result: authMethodsToPasskeyPb(authMethods), - }, nil + }), nil } func authMethodsToPasskeyPb(methods *query.AuthMethods) []*user.Passkey { diff --git a/internal/api/grpc/user/v2/passkey_test.go b/internal/api/grpc/user/v2/passkey_test.go index 9263012b98..6429dd7ce6 100644 --- a/internal/api/grpc/user/v2/passkey_test.go +++ b/internal/api/grpc/user/v2/passkey_test.go @@ -123,11 +123,11 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } @@ -181,7 +181,9 @@ func Test_passkeyDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) + if tt.want != nil { + assert.Equal(t, tt.want, got.Msg) + } }) } } @@ -242,9 +244,9 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + assert.Equal(t, tt.want, got.Msg) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2/password.go b/internal/api/grpc/user/v2/password.go index 55cf225c4b..a256a00355 100644 --- a/internal/api/grpc/user/v2/password.go +++ b/internal/api/grpc/user/v2/password.go @@ -3,23 +3,25 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) { +func (s *Server) PasswordReset(ctx context.Context, req *connect.Request[user.PasswordResetRequest]) (_ *connect.Response[user.PasswordResetResponse], err error) { var details *domain.ObjectDetails var code *string - switch m := req.GetMedium().(type) { + switch m := req.Msg.GetMedium().(type) { case *user.PasswordResetRequest_SendLink: - details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) + details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.Msg.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) case *user.PasswordResetRequest_ReturnCode: - details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.Msg.GetUserId()) case nil: - details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordReset(ctx, req.Msg.GetUserId()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m) } @@ -27,10 +29,10 @@ func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetReque return nil, err } - return &user.PasswordResetResponse{ + return connect.NewResponse(&user.PasswordResetResponse{ Details: object.DomainToDetailsPb(details), VerificationCode: code, - }, nil + }), nil } func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType { @@ -46,16 +48,16 @@ func notificationTypeToDomain(notificationType user.NotificationType) domain.Not } } -func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) { +func (s *Server) SetPassword(ctx context.Context, req *connect.Request[user.SetPasswordRequest]) (_ *connect.Response[user.SetPasswordResponse], err error) { var details *domain.ObjectDetails - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPasswordRequest_CurrentPassword: - details, err = s.command.ChangePassword(ctx, "", req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.ChangePassword(ctx, "", req.Msg.GetUserId(), v.CurrentPassword, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case *user.SetPasswordRequest_VerificationCode: - details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.Msg.GetUserId(), v.VerificationCode, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case nil: - details, err = s.command.SetPassword(ctx, "", req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPassword(ctx, "", req.Msg.GetUserId(), req.Msg.GetNewPassword().GetPassword(), req.Msg.GetNewPassword().GetChangeRequired()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v) } @@ -63,7 +65,7 @@ func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) return nil, err } - return &user.SetPasswordResponse{ + return connect.NewResponse(&user.SetPasswordResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/pat.go b/internal/api/grpc/user/v2/pat.go index 54f6e99367..0c90eeaebd 100644 --- a/internal/api/grpc/user/v2/pat.go +++ b/internal/api/grpc/user/v2/pat.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/zitadel/oidc/v3/pkg/oidc" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,13 +14,13 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPersonalAccessTokenRequest) (*user.AddPersonalAccessTokenResponse, error) { +func (s *Server) AddPersonalAccessToken(ctx context.Context, req *connect.Request[user.AddPersonalAccessTokenRequest]) (*connect.Response[user.AddPersonalAccessTokenResponse], error) { newPat := &command.PersonalAccessToken{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), - ExpirationDate: req.ExpirationDate.AsTime(), + ExpirationDate: req.Msg.GetExpirationDate().AsTime(), Scopes: []string{ oidc.ScopeOpenID, oidc.ScopeProfile, @@ -32,25 +33,25 @@ func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPerson if err != nil { return nil, err } - return &user.AddPersonalAccessTokenResponse{ + return connect.NewResponse(&user.AddPersonalAccessTokenResponse{ CreationDate: timestamppb.New(details.EventDate), TokenId: newPat.TokenID, Token: newPat.Token, - }, nil + }), nil } -func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *user.RemovePersonalAccessTokenRequest) (*user.RemovePersonalAccessTokenResponse, error) { +func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *connect.Request[user.RemovePersonalAccessTokenRequest]) (*connect.Response[user.RemovePersonalAccessTokenResponse], error) { objectDetails, err := s.command.RemovePersonalAccessToken(ctx, &command.PersonalAccessToken{ - TokenID: req.TokenId, + TokenID: req.Msg.GetTokenId(), ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), }) if err != nil { return nil, err } - return &user.RemovePersonalAccessTokenResponse{ + return connect.NewResponse(&user.RemovePersonalAccessTokenResponse{ DeletionDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/pat_query.go b/internal/api/grpc/user/v2/pat_query.go index 6bbd44d511..64231c1d93 100644 --- a/internal/api/grpc/user/v2/pat_query.go +++ b/internal/api/grpc/user/v2/pat_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" @@ -12,12 +13,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPersonalAccessTokensRequest) (*user.ListPersonalAccessTokensResponse, error) { - offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) +func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *connect.Request[user.ListPersonalAccessTokensRequest]) (*connect.Response[user.ListPersonalAccessTokensResponse], error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Msg.GetPagination()) if err != nil { return nil, err } - filters, err := patFiltersToQueries(req.Filters) + filters, err := patFiltersToQueries(req.Msg.GetFilters()) if err != nil { return nil, err } @@ -26,7 +27,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPer Offset: offset, Limit: limit, Asc: asc, - SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.SortingColumn), + SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.Msg.SortingColumn), }, Queries: filters, } @@ -48,7 +49,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPer ExpirationDate: timestamppb.New(pat.Expiration), } } - return resp, nil + return connect.NewResponse(resp), nil } func patFiltersToQueries(filters []*user.PersonalAccessTokensSearchFilter) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go index fdd5a140c1..4be616f7ea 100644 --- a/internal/api/grpc/user/v2/phone.go +++ b/internal/api/grpc/user/v2/phone.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { +func (s *Server) SetPhone(ctx context.Context, req *connect.Request[user.SetPhoneRequest]) (resp *connect.Response[user.SetPhoneResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPhoneRequest_SendCode: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_ReturnCode: - phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_IsVerified: - phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone()) + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.Msg.GetUserId(), req.Msg.GetPhone()) case nil: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) } @@ -30,42 +31,42 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp return nil, err } - return &user.SetPhoneResponse{ + return connect.NewResponse(&user.SetPhoneResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) { +func (s *Server) RemovePhone(ctx context.Context, req *connect.Request[user.RemovePhoneRequest]) (resp *connect.Response[user.RemovePhoneResponse], err error) { details, err := s.command.RemoveUserPhone(ctx, - req.GetUserId(), + req.Msg.GetUserId(), ) if err != nil { return nil, err } - return &user.RemovePhoneResponse{ + return connect.NewResponse(&user.RemovePhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) { +func (s *Server) ResendPhoneCode(ctx context.Context, req *connect.Request[user.ResendPhoneCodeRequest]) (resp *connect.Response[user.ResendPhoneCodeResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendPhoneCodeRequest_SendCode: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case *user.ResendPhoneCodeRequest_ReturnCode: - phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v) } @@ -73,30 +74,30 @@ func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeR return nil, err } - return &user.ResendPhoneCodeResponse{ + return connect.NewResponse(&user.ResendPhoneCodeResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { +func (s *Server) VerifyPhone(ctx context.Context, req *connect.Request[user.VerifyPhoneRequest]) (*connect.Response[user.VerifyPhoneResponse], error) { details, err := s.command.VerifyUserPhone(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyPhoneResponse{ + return connect.NewResponse(&user.VerifyPhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/secret.go b/internal/api/grpc/user/v2/secret.go index 1d54e1dde8..acc7aef8cb 100644 --- a/internal/api/grpc/user/v2/secret.go +++ b/internal/api/grpc/user/v2/secret.go @@ -3,37 +3,38 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddSecret(ctx context.Context, req *user.AddSecretRequest) (*user.AddSecretResponse, error) { +func (s *Server) AddSecret(ctx context.Context, req *connect.Request[user.AddSecretRequest]) (*connect.Response[user.AddSecretResponse], error) { newSecret := &command.GenerateMachineSecret{ PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), } - details, err := s.command.GenerateMachineSecret(ctx, req.UserId, "", newSecret) + details, err := s.command.GenerateMachineSecret(ctx, req.Msg.GetUserId(), "", newSecret) if err != nil { return nil, err } - return &user.AddSecretResponse{ + return connect.NewResponse(&user.AddSecretResponse{ CreationDate: timestamppb.New(details.EventDate), ClientSecret: newSecret.ClientSecret, - }, nil + }), nil } -func (s *Server) RemoveSecret(ctx context.Context, req *user.RemoveSecretRequest) (*user.RemoveSecretResponse, error) { +func (s *Server) RemoveSecret(ctx context.Context, req *connect.Request[user.RemoveSecretRequest]) (*connect.Response[user.RemoveSecretResponse], error) { details, err := s.command.RemoveMachineSecret( ctx, - req.UserId, + req.Msg.GetUserId(), "", s.command.NewPermissionCheckUserWrite(ctx), ) if err != nil { return nil, err } - return &user.RemoveSecretResponse{ + return connect.NewResponse(&user.RemoveSecretResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index e3c7e8011e..1f94906853 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -2,8 +2,10 @@ package user import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -13,12 +15,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2/userconnect" ) -var _ user.UserServiceServer = (*Server)(nil) +var _ userconnect.UserServiceHandler = (*Server)(nil) type Server struct { - user.UnimplementedUserServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -58,8 +60,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - user.RegisterUserServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return userconnect.NewUserServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return user.File_zitadel_user_v2_user_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/user/v2/totp.go b/internal/api/grpc/user/v2/totp.go index 9e2d028d72..51b615dac5 100644 --- a/internal/api/grpc/user/v2/totp.go +++ b/internal/api/grpc/user/v2/totp.go @@ -3,42 +3,44 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) { +func (s *Server) RegisterTOTP(ctx context.Context, req *connect.Request[user.RegisterTOTPRequest]) (*connect.Response[user.RegisterTOTPResponse], error) { return totpDetailsToPb( - s.command.AddUserTOTP(ctx, req.GetUserId(), ""), + s.command.AddUserTOTP(ctx, req.Msg.GetUserId(), ""), ) } -func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) { +func totpDetailsToPb(totp *domain.TOTP, err error) (*connect.Response[user.RegisterTOTPResponse], error) { if err != nil { return nil, err } - return &user.RegisterTOTPResponse{ + return connect.NewResponse(&user.RegisterTOTPResponse{ Details: object.DomainToDetailsPb(totp.ObjectDetails), Uri: totp.URI, Secret: totp.Secret, - }, nil + }), nil } -func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) { - objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), "") +func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *connect.Request[user.VerifyTOTPRegistrationRequest]) (*connect.Response[user.VerifyTOTPRegistrationResponse], error) { + objectDetails, err := s.command.CheckUserTOTP(ctx, req.Msg.GetUserId(), req.Msg.GetCode(), "") if err != nil { return nil, err } - return &user.VerifyTOTPRegistrationResponse{ + return connect.NewResponse(&user.VerifyTOTPRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveTOTP(ctx context.Context, req *user.RemoveTOTPRequest) (*user.RemoveTOTPResponse, error) { - objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.GetUserId(), "") +func (s *Server) RemoveTOTP(ctx context.Context, req *connect.Request[user.RemoveTOTPRequest]) (*connect.Response[user.RemoveTOTPResponse], error) { + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2/totp_test.go b/internal/api/grpc/user/v2/totp_test.go index 27ce6fb469..259f5ab5c6 100644 --- a/internal/api/grpc/user/v2/totp_test.go +++ b/internal/api/grpc/user/v2/totp_test.go @@ -63,7 +63,7 @@ func Test_totpDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := totpDetailsToPb(tt.args.otp, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want) } }) diff --git a/internal/api/grpc/user/v2/u2f.go b/internal/api/grpc/user/v2/u2f.go index 60c0f5ab07..bd12ea0dac 100644 --- a/internal/api/grpc/user/v2/u2f.go +++ b/internal/api/grpc/user/v2/u2f.go @@ -3,50 +3,52 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) { +func (s *Server) RegisterU2F(ctx context.Context, req *connect.Request[user.RegisterU2FRequest]) (*connect.Response[user.RegisterU2FResponse], error) { return u2fRegistrationDetailsToPb( - s.command.RegisterUserU2F(ctx, req.GetUserId(), "", req.GetDomain()), + s.command.RegisterUserU2F(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain()), ) } -func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) { +func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterU2FResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterU2FResponse{ + return connect.NewResponse(&user.RegisterU2FResponse{ Details: objectDetails, U2FId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyU2FRegistration(ctx context.Context, req *connect.Request[user.VerifyU2FRegistrationRequest]) (*connect.Response[user.VerifyU2FRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal") } - objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), "", req.GetTokenName(), "", pkc) + objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetTokenName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyU2FRegistrationResponse{ + return connect.NewResponse(&user.VerifyU2FRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveU2F(ctx context.Context, req *user.RemoveU2FRequest) (*user.RemoveU2FResponse, error) { - objectDetails, err := s.command.HumanRemoveU2F(ctx, req.GetUserId(), req.GetU2FId(), "") +func (s *Server) RemoveU2F(ctx context.Context, req *connect.Request[user.RemoveU2FRequest]) (*connect.Response[user.RemoveU2FResponse], error) { + objectDetails, err := s.command.HumanRemoveU2F(ctx, req.Msg.GetUserId(), req.Msg.GetU2FId(), "") if err != nil { return nil, err } - return &user.RemoveU2FResponse{ + return connect.NewResponse(&user.RemoveU2FResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/u2f_test.go b/internal/api/grpc/user/v2/u2f_test.go index fae3ba1cdb..f6798a6f89 100644 --- a/internal/api/grpc/user/v2/u2f_test.go +++ b/internal/api/grpc/user/v2/u2f_test.go @@ -92,11 +92,11 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 6b4b2da75b..95c2883195 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -4,6 +4,7 @@ import ( "context" "io" + "connectrpc.com/connect" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" @@ -15,8 +16,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { - human, err := AddUserRequestToAddHuman(req) +func (s *Server) AddHumanUser(ctx context.Context, req *connect.Request[user.AddHumanUserRequest]) (_ *connect.Response[user.AddHumanUserResponse], err error) { + human, err := AddUserRequestToAddHuman(req.Msg) if err != nil { return nil, err } @@ -24,12 +25,12 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { return nil, err } - return &user.AddHumanUserResponse{ + return connect.NewResponse(&user.AddHumanUserResponse{ UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { @@ -117,8 +118,8 @@ func genderToDomain(gender user.Gender) domain.Gender { } } -func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := updateHumanUserRequestToChangeHuman(req) +func (s *Server) UpdateHumanUser(ctx context.Context, req *connect.Request[user.UpdateHumanUserRequest]) (_ *connect.Response[user.UpdateHumanUserResponse], err error) { + human, err := updateHumanUserRequestToChangeHuman(req.Msg) if err != nil { return nil, err } @@ -126,51 +127,51 @@ func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserR if err != nil { return nil, err } - return &user.UpdateHumanUserResponse{ + return connect.NewResponse(&user.UpdateHumanUserResponse{ Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } -func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { - details, err := s.command.LockUserV2(ctx, req.UserId) +func (s *Server) LockUser(ctx context.Context, req *connect.Request[user.LockUserRequest]) (_ *connect.Response[user.LockUserResponse], err error) { + details, err := s.command.LockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.LockUserResponse{ + return connect.NewResponse(&user.LockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { - details, err := s.command.UnlockUserV2(ctx, req.UserId) +func (s *Server) UnlockUser(ctx context.Context, req *connect.Request[user.UnlockUserRequest]) (_ *connect.Response[user.UnlockUserResponse], err error) { + details, err := s.command.UnlockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.UnlockUserResponse{ + return connect.NewResponse(&user.UnlockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { - details, err := s.command.DeactivateUserV2(ctx, req.UserId) +func (s *Server) DeactivateUser(ctx context.Context, req *connect.Request[user.DeactivateUserRequest]) (_ *connect.Response[user.DeactivateUserResponse], err error) { + details, err := s.command.DeactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.DeactivateUserResponse{ + return connect.NewResponse(&user.DeactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { - details, err := s.command.ReactivateUserV2(ctx, req.UserId) +func (s *Server) ReactivateUser(ctx context.Context, req *connect.Request[user.ReactivateUserRequest]) (_ *connect.Response[user.ReactivateUserResponse], err error) { + details, err := s.command.ReactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.ReactivateUserResponse{ + return connect.NewResponse(&user.ReactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { @@ -182,18 +183,18 @@ func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { return &pVal } -func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) +func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.UserId, "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) if err != nil { return nil, err } - return &user.DeleteUserResponse{ + return connect.NewResponse(&user.DeleteUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { @@ -268,35 +269,35 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { return converted } -func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { - authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.GetUserId(), true, req.GetDomainQuery().GetIncludeWithoutDomain(), req.GetDomainQuery().GetDomain()) +func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *connect.Request[user.ListAuthenticationMethodTypesRequest]) (*connect.Response[user.ListAuthenticationMethodTypesResponse], error) { + authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.Msg.GetUserId(), true, req.Msg.GetDomainQuery().GetIncludeWithoutDomain(), req.Msg.GetDomainQuery().GetDomain()) if err != nil { return nil, err } - return &user.ListAuthenticationMethodTypesResponse{ + return connect.NewResponse(&user.ListAuthenticationMethodTypesResponse{ Details: object.ToListDetails(authMethods.SearchResponse), AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes), - }, nil + }), nil } -func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAuthenticationFactorsRequest) (*user.ListAuthenticationFactorsResponse, error) { +func (s *Server) ListAuthenticationFactors(ctx context.Context, req *connect.Request[user.ListAuthenticationFactorsRequest]) (*connect.Response[user.ListAuthenticationFactorsResponse], error) { query := new(query.UserAuthMethodSearchQueries) - if err := query.AppendUserIDQuery(req.UserId); err != nil { + if err := query.AppendUserIDQuery(req.Msg.GetUserId()); err != nil { return nil, err } authMethodsType := []domain.UserAuthMethodType{domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail} - if len(req.GetAuthFactors()) > 0 { - authMethodsType = object.AuthFactorsToPb(req.GetAuthFactors()) + if len(req.Msg.GetAuthFactors()) > 0 { + authMethodsType = object.AuthFactorsToPb(req.Msg.GetAuthFactors()) } if err := query.AppendAuthMethodsQuery(authMethodsType...); err != nil { return nil, err } states := []domain.MFAState{domain.MFAStateReady} - if len(req.GetStates()) > 0 { - states = object.AuthFactorStatesToPb(req.GetStates()) + if len(req.Msg.GetStates()) > 0 { + states = object.AuthFactorStatesToPb(req.Msg.GetStates()) } if err := query.AppendStatesQuery(states...); err != nil { return nil, err @@ -307,9 +308,9 @@ func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAu return nil, err } - return &user.ListAuthenticationFactorsResponse{ + return connect.NewResponse(&user.ListAuthenticationFactorsResponse{ Result: object.AuthMethodsToPb(authMethods), - }, nil + }), nil } func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { @@ -343,8 +344,8 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio } } -func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCodeRequest) (*user.CreateInviteCodeResponse, error) { - invite, err := createInviteCodeRequestToCommand(req) +func (s *Server) CreateInviteCode(ctx context.Context, req *connect.Request[user.CreateInviteCodeRequest]) (*connect.Response[user.CreateInviteCodeResponse], error) { + invite, err := createInviteCodeRequestToCommand(req.Msg) if err != nil { return nil, err } @@ -352,30 +353,30 @@ func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCod if err != nil { return nil, err } - return &user.CreateInviteCodeResponse{ + return connect.NewResponse(&user.CreateInviteCodeResponse{ Details: object.DomainToDetailsPb(details), InviteCode: code, - }, nil + }), nil } -func (s *Server) ResendInviteCode(ctx context.Context, req *user.ResendInviteCodeRequest) (*user.ResendInviteCodeResponse, error) { - details, err := s.command.ResendInviteCode(ctx, req.GetUserId(), "", "") +func (s *Server) ResendInviteCode(ctx context.Context, req *connect.Request[user.ResendInviteCodeRequest]) (*connect.Response[user.ResendInviteCodeResponse], error) { + details, err := s.command.ResendInviteCode(ctx, req.Msg.GetUserId(), "", "") if err != nil { return nil, err } - return &user.ResendInviteCodeResponse{ + return connect.NewResponse(&user.ResendInviteCodeResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) VerifyInviteCode(ctx context.Context, req *user.VerifyInviteCodeRequest) (*user.VerifyInviteCodeResponse, error) { - details, err := s.command.VerifyInviteCode(ctx, req.GetUserId(), req.GetVerificationCode()) +func (s *Server) VerifyInviteCode(ctx context.Context, req *connect.Request[user.VerifyInviteCodeRequest]) (*connect.Response[user.VerifyInviteCodeResponse], error) { + details, err := s.command.VerifyInviteCode(ctx, req.Msg.GetUserId(), req.Msg.GetVerificationCode()) if err != nil { return nil, err } - return &user.VerifyInviteCodeResponse{ + return connect.NewResponse(&user.VerifyInviteCodeResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*command.CreateUserInvite, error) { @@ -394,33 +395,33 @@ func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*comma } } -func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *user.HumanMFAInitSkippedRequest) (_ *user.HumanMFAInitSkippedResponse, err error) { - details, err := s.command.HumanMFAInitSkippedV2(ctx, req.UserId) +func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *connect.Request[user.HumanMFAInitSkippedRequest]) (_ *connect.Response[user.HumanMFAInitSkippedResponse], err error) { + details, err := s.command.HumanMFAInitSkippedV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.HumanMFAInitSkippedResponse{ + return connect.NewResponse(&user.HumanMFAInitSkippedResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.CreateUserResponse, error) { - switch userType := req.GetUserType().(type) { +func (s *Server) CreateUser(ctx context.Context, req *connect.Request[user.CreateUserRequest]) (*connect.Response[user.CreateUserResponse], error) { + switch userType := req.Msg.GetUserType().(type) { case *user.CreateUserRequest_Human_: - return s.createUserTypeHuman(ctx, userType.Human, req.OrganizationId, req.Username, req.UserId) + return s.createUserTypeHuman(ctx, userType.Human, req.Msg.GetOrganizationId(), req.Msg.Username, req.Msg.UserId) case *user.CreateUserRequest_Machine_: - return s.createUserTypeMachine(ctx, userType.Machine, req.OrganizationId, req.GetUsername(), req.GetUserId()) + return s.createUserTypeMachine(ctx, userType.Machine, req.Msg.GetOrganizationId(), req.Msg.GetUsername(), req.Msg.GetUserId()) default: return nil, zerrors.ThrowInternal(nil, "", "user type is not implemented") } } -func (s *Server) UpdateUser(ctx context.Context, req *user.UpdateUserRequest) (*user.UpdateUserResponse, error) { - switch userType := req.GetUserType().(type) { +func (s *Server) UpdateUser(ctx context.Context, req *connect.Request[user.UpdateUserRequest]) (*connect.Response[user.UpdateUserResponse], error) { + switch userType := req.Msg.GetUserType().(type) { case *user.UpdateUserRequest_Human_: - return s.updateUserTypeHuman(ctx, userType.Human, req.UserId, req.Username) + return s.updateUserTypeHuman(ctx, userType.Human, req.Msg.GetUserId(), req.Msg.Username) case *user.UpdateUserRequest_Machine_: - return s.updateUserTypeMachine(ctx, userType.Machine, req.UserId, req.Username) + return s.updateUserTypeMachine(ctx, userType.Machine, req.Msg.GetUserId(), req.Msg.Username) default: return nil, zerrors.ThrowUnimplemented(nil, "", "user type is not implemented") } diff --git a/internal/api/grpc/user/v2/user_query.go b/internal/api/grpc/user/v2/user_query.go index dc886462be..5f5603af31 100644 --- a/internal/api/grpc/user/v2/user_query.go +++ b/internal/api/grpc/user/v2/user_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,12 +14,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { - resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.GetUserId(), s.checkPermission) +func (s *Server) GetUserByID(ctx context.Context, req *connect.Request[user.GetUserByIDRequest]) (_ *connect.Response[user.GetUserByIDResponse], err error) { + resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.Msg.GetUserId(), s.checkPermission) if err != nil { return nil, err } - return &user.GetUserByIDResponse{ + return connect.NewResponse(&user.GetUserByIDResponse{ Details: object.DomainToDetailsPb(&domain.ObjectDetails{ Sequence: resp.Sequence, CreationDate: resp.CreationDate, @@ -26,11 +27,11 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) ResourceOwner: resp.ResourceOwner, }), User: userToPb(resp, s.assetAPIPrefix(ctx)), - }, nil + }), nil } -func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, err := listUsersRequestToModel(req) +func (s *Server) ListUsers(ctx context.Context, req *connect.Request[user.ListUsersRequest]) (*connect.Response[user.ListUsersResponse], error) { + queries, err := listUsersRequestToModel(req.Msg) if err != nil { return nil, err } @@ -38,10 +39,10 @@ func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*us if err != nil { return nil, err } - return &user.ListUsersResponse{ + return connect.NewResponse(&user.ListUsersResponse{ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func UsersToPb(users []*query.User, assetPrefix string) []*user.User { diff --git a/internal/api/grpc/user/v2beta/email.go b/internal/api/grpc/user/v2beta/email.go index 38cc73c75c..474111f767 100644 --- a/internal/api/grpc/user/v2beta/email.go +++ b/internal/api/grpc/user/v2beta/email.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { +func (s *Server) SetEmail(ctx context.Context, req *connect.Request[user.SetEmailRequest]) (resp *connect.Response[user.SetEmailResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetEmailRequest_SendCode: - email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SetEmailRequest_ReturnCode: - email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmailReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) case *user.SetEmailRequest_IsVerified: - email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail()) + email, err = s.command.ChangeUserEmailVerified(ctx, req.Msg.GetUserId(), req.Msg.GetEmail()) case nil: - email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmail(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v) } @@ -30,26 +31,26 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp return nil, err } - return &user.SetEmailResponse{ + return connect.NewResponse(&user.SetEmailResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) { +func (s *Server) ResendEmailCode(ctx context.Context, req *connect.Request[user.ResendEmailCodeRequest]) (resp *connect.Response[user.ResendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendEmailCodeRequest_SendCode: - email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.ResendEmailCodeRequest_ReturnCode: - email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v) } @@ -57,30 +58,30 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR return nil, err } - return &user.ResendEmailCodeResponse{ + return connect.NewResponse(&user.ResendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { +func (s *Server) VerifyEmail(ctx context.Context, req *connect.Request[user.VerifyEmailRequest]) (*connect.Response[user.VerifyEmailResponse], error) { details, err := s.command.VerifyUserEmail(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyEmailResponse{ + return connect.NewResponse(&user.VerifyEmailResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/otp.go b/internal/api/grpc/user/v2beta/otp.go index c11aa4c1a4..99919ce047 100644 --- a/internal/api/grpc/user/v2beta/otp.go +++ b/internal/api/grpc/user/v2beta/otp.go @@ -3,40 +3,42 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { - details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) AddOTPSMS(ctx context.Context, req *connect.Request[user.AddOTPSMSRequest]) (*connect.Response[user.AddOTPSMSResponse], error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPSMS(ctx context.Context, req *connect.Request[user.RemoveOTPSMSRequest]) (*connect.Response[user.RemoveOTPSMSResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } -func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { - details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) AddOTPEmail(ctx context.Context, req *connect.Request[user.AddOTPEmailRequest]) (*connect.Response[user.AddOTPEmailResponse], error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPEmail(ctx context.Context, req *connect.Request[user.RemoveOTPEmailRequest]) (*connect.Response[user.RemoveOTPEmailResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2beta/passkey.go b/internal/api/grpc/user/v2beta/passkey.go index 2df267f3fd..a63ac708b4 100644 --- a/internal/api/grpc/user/v2beta/passkey.go +++ b/internal/api/grpc/user/v2beta/passkey.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/structpb" object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" @@ -12,17 +13,17 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) { +func (s *Server) RegisterPasskey(ctx context.Context, req *connect.Request[user.RegisterPasskeyRequest]) (resp *connect.Response[user.RegisterPasskeyResponse], err error) { var ( - authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator()) + authenticator = passkeyAuthenticatorToDomain(req.Msg.GetAuthenticator()) ) - if code := req.GetCode(); code != nil { + if code := req.Msg.GetCode(); code != nil { return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), "", authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg), + s.command.RegisterUserPasskeyWithCode(ctx, req.Msg.GetUserId(), "", authenticator, code.Id, code.Code, req.Msg.GetDomain(), s.userCodeAlg), ) } return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskey(ctx, req.GetUserId(), "", req.GetDomain(), authenticator), + s.command.RegisterUserPasskey(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain(), authenticator), ) } @@ -50,69 +51,69 @@ func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails return object.DomainToDetailsPb(details.ObjectDetails), options, nil } -func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) { +func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterPasskeyResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterPasskeyResponse{ + return connect.NewResponse(&user.RegisterPasskeyResponse{ Details: objectDetails, PasskeyId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *connect.Request[user.VerifyPasskeyRegistrationRequest]) (*connect.Response[user.VerifyPasskeyRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal") } - objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), "", req.GetPasskeyName(), "", pkc) + objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetPasskeyName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyPasskeyRegistrationResponse{ + return connect.NewResponse(&user.VerifyPasskeyRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) { - switch medium := req.Medium.(type) { +func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *connect.Request[user.CreatePasskeyRegistrationLinkRequest]) (resp *connect.Response[user.CreatePasskeyRegistrationLinkResponse], err error) { + switch medium := req.Msg.Medium.(type) { case nil: return passkeyDetailsToPb( - s.command.AddUserPasskeyCode(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCode(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) case *user.CreatePasskeyRegistrationLinkRequest_SendLink: return passkeyDetailsToPb( - s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), + s.command.AddUserPasskeyCodeURLTemplate(ctx, req.Msg.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), ) case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode: return passkeyCodeDetailsToPb( - s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCodeReturn(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium) } } -func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details.ObjectDetails), Code: &user.PasskeyRegistrationCode{ Id: details.CodeID, Code: details.Code, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/passkey_test.go b/internal/api/grpc/user/v2beta/passkey_test.go index f4a48ed941..12ef8ed02f 100644 --- a/internal/api/grpc/user/v2beta/passkey_test.go +++ b/internal/api/grpc/user/v2beta/passkey_test.go @@ -123,11 +123,11 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } @@ -181,7 +181,9 @@ func Test_passkeyDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) + if tt.want != nil { + assert.Equal(t, tt.want, got.Msg) + } }) } } @@ -242,9 +244,9 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + assert.Equal(t, tt.want, got.Msg) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2beta/password.go b/internal/api/grpc/user/v2beta/password.go index 0de1262215..ae9a549db0 100644 --- a/internal/api/grpc/user/v2beta/password.go +++ b/internal/api/grpc/user/v2beta/password.go @@ -3,23 +3,25 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) { +func (s *Server) PasswordReset(ctx context.Context, req *connect.Request[user.PasswordResetRequest]) (_ *connect.Response[user.PasswordResetResponse], err error) { var details *domain.ObjectDetails var code *string - switch m := req.GetMedium().(type) { + switch m := req.Msg.GetMedium().(type) { case *user.PasswordResetRequest_SendLink: - details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) + details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.Msg.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) case *user.PasswordResetRequest_ReturnCode: - details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.Msg.GetUserId()) case nil: - details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordReset(ctx, req.Msg.GetUserId()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m) } @@ -27,10 +29,10 @@ func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetReque return nil, err } - return &user.PasswordResetResponse{ + return connect.NewResponse(&user.PasswordResetResponse{ Details: object.DomainToDetailsPb(details), VerificationCode: code, - }, nil + }), nil } func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType { @@ -46,16 +48,16 @@ func notificationTypeToDomain(notificationType user.NotificationType) domain.Not } } -func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) { +func (s *Server) SetPassword(ctx context.Context, req *connect.Request[user.SetPasswordRequest]) (_ *connect.Response[user.SetPasswordResponse], err error) { var details *domain.ObjectDetails - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPasswordRequest_CurrentPassword: - details, err = s.command.ChangePassword(ctx, "", req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.ChangePassword(ctx, "", req.Msg.GetUserId(), v.CurrentPassword, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case *user.SetPasswordRequest_VerificationCode: - details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.Msg.GetUserId(), v.VerificationCode, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case nil: - details, err = s.command.SetPassword(ctx, "", req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPassword(ctx, "", req.Msg.GetUserId(), req.Msg.GetNewPassword().GetPassword(), req.Msg.GetNewPassword().GetChangeRequired()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v) } @@ -63,7 +65,7 @@ func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) return nil, err } - return &user.SetPasswordResponse{ + return connect.NewResponse(&user.SetPasswordResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/phone.go b/internal/api/grpc/user/v2beta/phone.go index eac7eb4e31..20ef2075ab 100644 --- a/internal/api/grpc/user/v2beta/phone.go +++ b/internal/api/grpc/user/v2beta/phone.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { +func (s *Server) SetPhone(ctx context.Context, req *connect.Request[user.SetPhoneRequest]) (resp *connect.Response[user.SetPhoneResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPhoneRequest_SendCode: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_ReturnCode: - phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_IsVerified: - phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone()) + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.Msg.GetUserId(), req.Msg.GetPhone()) case nil: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) } @@ -30,42 +31,42 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp return nil, err } - return &user.SetPhoneResponse{ + return connect.NewResponse(&user.SetPhoneResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) { +func (s *Server) RemovePhone(ctx context.Context, req *connect.Request[user.RemovePhoneRequest]) (resp *connect.Response[user.RemovePhoneResponse], err error) { details, err := s.command.RemoveUserPhone(ctx, - req.GetUserId(), + req.Msg.GetUserId(), ) if err != nil { return nil, err } - return &user.RemovePhoneResponse{ + return connect.NewResponse(&user.RemovePhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) { +func (s *Server) ResendPhoneCode(ctx context.Context, req *connect.Request[user.ResendPhoneCodeRequest]) (resp *connect.Response[user.ResendPhoneCodeResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendPhoneCodeRequest_SendCode: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case *user.ResendPhoneCodeRequest_ReturnCode: - phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v) } @@ -73,30 +74,30 @@ func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeR return nil, err } - return &user.ResendPhoneCodeResponse{ + return connect.NewResponse(&user.ResendPhoneCodeResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { +func (s *Server) VerifyPhone(ctx context.Context, req *connect.Request[user.VerifyPhoneRequest]) (*connect.Response[user.VerifyPhoneResponse], error) { details, err := s.command.VerifyUserPhone(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyPhoneResponse{ + return connect.NewResponse(&user.VerifyPhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index 46b009a72e..b9654ea97c 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,23 +14,23 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { - resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.GetUserId(), s.checkPermission) +func (s *Server) GetUserByID(ctx context.Context, req *connect.Request[user.GetUserByIDRequest]) (_ *connect.Response[user.GetUserByIDResponse], err error) { + resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.Msg.GetUserId(), s.checkPermission) if err != nil { return nil, err } - return &user.GetUserByIDResponse{ + return connect.NewResponse(&user.GetUserByIDResponse{ Details: object.DomainToDetailsPb(&domain.ObjectDetails{ Sequence: resp.Sequence, EventDate: resp.ChangeDate, ResourceOwner: resp.ResourceOwner, }), User: userToPb(resp, s.assetAPIPrefix(ctx)), - }, nil + }), nil } -func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, err := listUsersRequestToModel(req) +func (s *Server) ListUsers(ctx context.Context, req *connect.Request[user.ListUsersRequest]) (*connect.Response[user.ListUsersResponse], error) { + queries, err := listUsersRequestToModel(req.Msg) if err != nil { return nil, err } @@ -37,10 +38,10 @@ func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*us if err != nil { return nil, err } - return &user.ListUsersResponse{ + return connect.NewResponse(&user.ListUsersResponse{ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func UsersToPb(users []*query.User, assetPrefix string) []*user.User { diff --git a/internal/api/grpc/user/v2beta/server.go b/internal/api/grpc/user/v2beta/server.go index 93af47f58b..7e3934a2c1 100644 --- a/internal/api/grpc/user/v2beta/server.go +++ b/internal/api/grpc/user/v2beta/server.go @@ -2,8 +2,10 @@ package user import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -12,12 +14,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2beta/userconnect" ) -var _ user.UserServiceServer = (*Server)(nil) +var _ userconnect.UserServiceHandler = (*Server)(nil) type Server struct { - user.UnimplementedUserServiceServer command *command.Commands query *query.Queries userCodeAlg crypto.EncryptionAlgorithm @@ -54,8 +56,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - user.RegisterUserServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return userconnect.NewUserServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return user.File_zitadel_user_v2beta_user_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/user/v2beta/totp.go b/internal/api/grpc/user/v2beta/totp.go index 2ef47a9817..e7bd01b2b6 100644 --- a/internal/api/grpc/user/v2beta/totp.go +++ b/internal/api/grpc/user/v2beta/totp.go @@ -3,42 +3,44 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" "github.com/zitadel/zitadel/internal/domain" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) { +func (s *Server) RegisterTOTP(ctx context.Context, req *connect.Request[user.RegisterTOTPRequest]) (*connect.Response[user.RegisterTOTPResponse], error) { return totpDetailsToPb( - s.command.AddUserTOTP(ctx, req.GetUserId(), ""), + s.command.AddUserTOTP(ctx, req.Msg.GetUserId(), ""), ) } -func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) { +func totpDetailsToPb(totp *domain.TOTP, err error) (*connect.Response[user.RegisterTOTPResponse], error) { if err != nil { return nil, err } - return &user.RegisterTOTPResponse{ + return connect.NewResponse(&user.RegisterTOTPResponse{ Details: object.DomainToDetailsPb(totp.ObjectDetails), Uri: totp.URI, Secret: totp.Secret, - }, nil + }), nil } -func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) { - objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), "") +func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *connect.Request[user.VerifyTOTPRegistrationRequest]) (*connect.Response[user.VerifyTOTPRegistrationResponse], error) { + objectDetails, err := s.command.CheckUserTOTP(ctx, req.Msg.GetUserId(), req.Msg.GetCode(), "") if err != nil { return nil, err } - return &user.VerifyTOTPRegistrationResponse{ + return connect.NewResponse(&user.VerifyTOTPRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveTOTP(ctx context.Context, req *user.RemoveTOTPRequest) (*user.RemoveTOTPResponse, error) { - objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.GetUserId(), "") +func (s *Server) RemoveTOTP(ctx context.Context, req *connect.Request[user.RemoveTOTPRequest]) (*connect.Response[user.RemoveTOTPResponse], error) { + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2beta/totp_test.go b/internal/api/grpc/user/v2beta/totp_test.go index 81a54675f2..77c6e5c343 100644 --- a/internal/api/grpc/user/v2beta/totp_test.go +++ b/internal/api/grpc/user/v2beta/totp_test.go @@ -63,7 +63,7 @@ func Test_totpDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := totpDetailsToPb(tt.args.otp, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want) } }) diff --git a/internal/api/grpc/user/v2beta/u2f.go b/internal/api/grpc/user/v2beta/u2f.go index e23a22b8b5..a6823a4bc0 100644 --- a/internal/api/grpc/user/v2beta/u2f.go +++ b/internal/api/grpc/user/v2beta/u2f.go @@ -3,40 +3,42 @@ package user import ( "context" + "connectrpc.com/connect" + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) { +func (s *Server) RegisterU2F(ctx context.Context, req *connect.Request[user.RegisterU2FRequest]) (*connect.Response[user.RegisterU2FResponse], error) { return u2fRegistrationDetailsToPb( - s.command.RegisterUserU2F(ctx, req.GetUserId(), "", req.GetDomain()), + s.command.RegisterUserU2F(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain()), ) } -func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) { +func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterU2FResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterU2FResponse{ + return connect.NewResponse(&user.RegisterU2FResponse{ Details: objectDetails, U2FId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyU2FRegistration(ctx context.Context, req *connect.Request[user.VerifyU2FRegistrationRequest]) (*connect.Response[user.VerifyU2FRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal") } - objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), "", req.GetTokenName(), "", pkc) + objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetTokenName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyU2FRegistrationResponse{ + return connect.NewResponse(&user.VerifyU2FRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/u2f_test.go b/internal/api/grpc/user/v2beta/u2f_test.go index 53f2a0bb8c..ac99c0d1eb 100644 --- a/internal/api/grpc/user/v2beta/u2f_test.go +++ b/internal/api/grpc/user/v2beta/u2f_test.go @@ -92,11 +92,11 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go index 49f0c7d9c7..e5b2094d2c 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -6,6 +6,7 @@ import ( "io" "time" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -23,8 +24,8 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { - human, err := AddUserRequestToAddHuman(req) +func (s *Server) AddHumanUser(ctx context.Context, req *connect.Request[user.AddHumanUserRequest]) (_ *connect.Response[user.AddHumanUserResponse], err error) { + human, err := AddUserRequestToAddHuman(req.Msg) if err != nil { return nil, err } @@ -32,12 +33,12 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { return nil, err } - return &user.AddHumanUserResponse{ + return connect.NewResponse(&user.AddHumanUserResponse{ UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { @@ -115,8 +116,8 @@ func genderToDomain(gender user.Gender) domain.Gender { } } -func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := UpdateUserRequestToChangeHuman(req) +func (s *Server) UpdateHumanUser(ctx context.Context, req *connect.Request[user.UpdateHumanUserRequest]) (_ *connect.Response[user.UpdateHumanUserResponse], err error) { + human, err := UpdateUserRequestToChangeHuman(req.Msg) if err != nil { return nil, err } @@ -124,51 +125,51 @@ func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserR if err != nil { return nil, err } - return &user.UpdateHumanUserResponse{ + return connect.NewResponse(&user.UpdateHumanUserResponse{ Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } -func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { - details, err := s.command.LockUserV2(ctx, req.UserId) +func (s *Server) LockUser(ctx context.Context, req *connect.Request[user.LockUserRequest]) (_ *connect.Response[user.LockUserResponse], err error) { + details, err := s.command.LockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.LockUserResponse{ + return connect.NewResponse(&user.LockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { - details, err := s.command.UnlockUserV2(ctx, req.UserId) +func (s *Server) UnlockUser(ctx context.Context, req *connect.Request[user.UnlockUserRequest]) (_ *connect.Response[user.UnlockUserResponse], err error) { + details, err := s.command.UnlockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.UnlockUserResponse{ + return connect.NewResponse(&user.UnlockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { - details, err := s.command.DeactivateUserV2(ctx, req.UserId) +func (s *Server) DeactivateUser(ctx context.Context, req *connect.Request[user.DeactivateUserRequest]) (_ *connect.Response[user.DeactivateUserResponse], err error) { + details, err := s.command.DeactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.DeactivateUserResponse{ + return connect.NewResponse(&user.DeactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { - details, err := s.command.ReactivateUserV2(ctx, req.UserId) +func (s *Server) ReactivateUser(ctx context.Context, req *connect.Request[user.ReactivateUserRequest]) (_ *connect.Response[user.ReactivateUserResponse], err error) { + details, err := s.command.ReactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.ReactivateUserResponse{ + return connect.NewResponse(&user.ReactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { @@ -260,32 +261,32 @@ func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { } } -func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { - details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ - IDPID: req.GetIdpLink().GetIdpId(), - DisplayName: req.GetIdpLink().GetUserName(), - IDPExternalID: req.GetIdpLink().GetUserId(), +func (s *Server) AddIDPLink(ctx context.Context, req *connect.Request[user.AddIDPLinkRequest]) (_ *connect.Response[user.AddIDPLinkResponse], err error) { + details, err := s.command.AddUserIDPLink(ctx, req.Msg.GetUserId(), "", &command.AddLink{ + IDPID: req.Msg.GetIdpLink().GetIdpId(), + DisplayName: req.Msg.GetIdpLink().GetUserName(), + IDPExternalID: req.Msg.GetIdpLink().GetUserId(), }) if err != nil { return nil, err } - return &user.AddIDPLinkResponse{ + return connect.NewResponse(&user.AddIDPLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) +func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.UserId, "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) if err != nil { return nil, err } - return &user.DeleteUserResponse{ + return connect.NewResponse(&user.DeleteUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { @@ -360,18 +361,18 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { return converted } -func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) { - switch t := req.GetContent().(type) { +func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.Request[user.StartIdentityProviderIntentRequest]) (_ *connect.Response[user.StartIdentityProviderIntentResponse], err error) { + switch t := req.Msg.GetContent().(type) { case *user.StartIdentityProviderIntentRequest_Urls: - return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls) + return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls) case *user.StartIdentityProviderIntentRequest_Ldap: - return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap) + return s.startLDAPIntent(ctx, req.Msg.GetIdpId(), t.Ldap) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderIntent not implemented", t) } } -func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) if err != nil { return nil, err @@ -386,12 +387,12 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re } switch a := auth.(type) { case *idp.RedirectAuth: - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, - }, nil + }), nil case *idp.FormAuth: - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_FormData{ FormData: &user.FormData{ @@ -399,12 +400,12 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re Fields: a.Fields, }, }, - }, nil + }), nil } return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } -func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { intentWriteModel, details, err := s.command.CreateIntent(ctx, "", idpID, "", "", authz.GetInstance(ctx).InstanceID(), nil) if err != nil { return nil, err @@ -420,7 +421,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti if err != nil { return nil, err } - return &user.StartIdentityProviderIntentResponse{ + return connect.NewResponse(&user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), NextStep: &user.StartIdentityProviderIntentResponse_IdpIntent{ IdpIntent: &user.IDPIntent{ @@ -429,7 +430,7 @@ func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredenti UserId: userID, }, }, - }, nil + }), nil } func (s *Server) checkLinkedExternalUser(ctx context.Context, idpID, externalUserID string) (string, error) { @@ -483,12 +484,12 @@ func (s *Server) ldapLogin(ctx context.Context, idpID, username, password string return externalUser, userID, session, nil } -func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.RetrieveIdentityProviderIntentRequest) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { - intent, err := s.command.GetIntentWriteModel(ctx, req.GetIdpIntentId(), "") +func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *connect.Request[user.RetrieveIdentityProviderIntentRequest]) (_ *connect.Response[user.RetrieveIdentityProviderIntentResponse], err error) { + intent, err := s.command.GetIntentWriteModel(ctx, req.Msg.GetIdpIntentId(), "") if err != nil { return nil, err } - if err := s.checkIntentToken(req.GetIdpIntentToken(), intent.AggregateID); err != nil { + if err := s.checkIntentToken(req.Msg.GetIdpIntentToken(), intent.AggregateID); err != nil { return nil, err } if intent.State != domain.IDPIntentStateSucceeded { @@ -500,7 +501,7 @@ func (s *Server) RetrieveIdentityProviderIntent(ctx context.Context, req *user.R return idpIntentToIDPIntentPb(intent, s.idpAlg) } -func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *user.RetrieveIdentityProviderIntentResponse, err error) { +func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.EncryptionAlgorithm) (_ *connect.Response[user.RetrieveIdentityProviderIntentResponse], err error) { rawInformation := new(structpb.Struct) err = rawInformation.UnmarshalJSON(intent.IDPUser) if err != nil { @@ -539,7 +540,7 @@ func idpIntentToIDPIntentPb(intent *command.IDPIntentWriteModel, alg crypto.Encr information.IdpInformation.Access = IDPSAMLResponseToPb(assertion) } - return information, nil + return connect.NewResponse(information), nil } func idpOAuthTokensToPb(idpIDToken string, idpAccessToken *crypto.CryptoValue, alg crypto.EncryptionAlgorithm) (_ *user.IDPInformation_Oauth, err error) { @@ -602,15 +603,15 @@ func (s *Server) checkIntentToken(token string, intentID string) error { return crypto.CheckToken(s.idpAlg, token, intentID) } -func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { - authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.GetUserId(), true, false, "") +func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *connect.Request[user.ListAuthenticationMethodTypesRequest]) (*connect.Response[user.ListAuthenticationMethodTypesResponse], error) { + authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.Msg.GetUserId(), true, false, "") if err != nil { return nil, err } - return &user.ListAuthenticationMethodTypesResponse{ + return connect.NewResponse(&user.ListAuthenticationMethodTypesResponse{ Details: object.ToListDetails(authMethods.SearchResponse), AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes), - }, nil + }), nil } func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { diff --git a/internal/api/grpc/user/v2beta/user_test.go b/internal/api/grpc/user/v2beta/user_test.go index 9e398e83ff..8973d61fcc 100644 --- a/internal/api/grpc/user/v2beta/user_test.go +++ b/internal/api/grpc/user/v2beta/user_test.go @@ -322,7 +322,9 @@ func Test_idpIntentToIDPIntentPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := idpIntentToIDPIntentPb(tt.args.intent, tt.args.alg) require.ErrorIs(t, err, tt.res.err) - grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.ProtoReflect(), grpc.CustomMappers) + if tt.res.resp != nil { + grpc.AllFieldsEqual(t, tt.res.resp.ProtoReflect(), got.Msg.ProtoReflect(), grpc.CustomMappers) + } }) } } diff --git a/internal/api/grpc/webkey/v2/integration_test/webkey_integration_test.go b/internal/api/grpc/webkey/v2/integration_test/webkey_integration_test.go new file mode 100644 index 0000000000..48777927cf --- /dev/null +++ b/internal/api/grpc/webkey/v2/integration_test/webkey_integration_test.go @@ -0,0 +1,216 @@ +//go:build integration + +package webkey_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +var ( + CTX context.Context +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + CTX = ctx + return m.Run() + }()) +} + +func TestServer_ListWebKeys(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + // After the feature is first enabled, we can expect 2 generated keys with the default config. + checkWebKeyListState(iamCtx, t, instance, 2, "", &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func TestServer_CreateWebKey(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + client := instance.Client.WebKeyV2 + + _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + }) + require.NoError(t, err) + + checkWebKeyListState(iamCtx, t, instance, 3, "", &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func TestServer_ActivateWebKey(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + client := instance.Client.WebKeyV2 + + resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + }) + require.NoError(t, err) + + _, err = client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ + Id: resp.GetId(), + }) + require.NoError(t, err) + + checkWebKeyListState(iamCtx, t, instance, 3, resp.GetId(), &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func TestServer_DeleteWebKey(t *testing.T) { + instance, iamCtx, creationDate := createInstance(t) + client := instance.Client.WebKeyV2 + + keyIDs := make([]string, 2) + for i := 0; i < 2; i++ { + resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + }) + require.NoError(t, err) + keyIDs[i] = resp.GetId() + } + _, err := client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ + Id: keyIDs[0], + }) + require.NoError(t, err) + + ok := t.Run("cannot delete active key", func(t *testing.T) { + _, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[0], + }) + require.Error(t, err) + s := status.Convert(err) + assert.Equal(t, codes.FailedPrecondition, s.Code()) + assert.Contains(t, s.Message(), "COMMAND-Chai1") + }) + if !ok { + return + } + + start := time.Now() + ok = t.Run("delete inactive key", func(t *testing.T) { + resp, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[1], + }) + require.NoError(t, err) + require.WithinRange(t, resp.GetDeletionDate().AsTime(), start, time.Now()) + }) + if !ok { + return + } + + ok = t.Run("delete inactive key again", func(t *testing.T) { + resp, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: keyIDs[1], + }) + require.NoError(t, err) + require.WithinRange(t, resp.GetDeletionDate().AsTime(), start, time.Now()) + }) + if !ok { + return + } + + ok = t.Run("delete not existing key", func(t *testing.T) { + resp, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ + Id: "not-existing", + }) + require.NoError(t, err) + require.Nil(t, resp.DeletionDate) + }) + if !ok { + return + } + + // There are 2 keys from feature setup, +2 created, -1 deleted = 3 + checkWebKeyListState(iamCtx, t, instance, 3, keyIDs[0], &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, creationDate) +} + +func createInstance(t *testing.T) (*integration.Instance, context.Context, *timestamppb.Timestamp) { + instance := integration.NewInstance(CTX) + creationDate := timestamppb.Now() + iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamCTX, time.Minute) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + resp, err := instance.Client.WebKeyV2.ListWebKeys(iamCTX, &webkey.ListWebKeysRequest{}) + assert.NoError(collect, err) + assert.Len(collect, resp.GetWebKeys(), 2) + + }, retryDuration, tick) + + return instance, iamCTX, creationDate +} + +func checkWebKeyListState(ctx context.Context, t *testing.T, instance *integration.Instance, nKeys int, expectActiveKeyID string, config any, creationDate *timestamppb.Timestamp) { + t.Helper() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) + assert.EventuallyWithT(t, func(collect *assert.CollectT) { + resp, err := instance.Client.WebKeyV2.ListWebKeys(ctx, &webkey.ListWebKeysRequest{}) + require.NoError(collect, err) + list := resp.GetWebKeys() + assert.Len(collect, list, nKeys) + + now := time.Now() + var gotActiveKeyID string + for _, key := range list { + assert.WithinRange(collect, key.GetCreationDate().AsTime(), now.Add(-time.Minute), now.Add(time.Minute)) + assert.WithinRange(collect, key.GetChangeDate().AsTime(), now.Add(-time.Minute), now.Add(time.Minute)) + assert.NotEqual(collect, webkey.State_STATE_UNSPECIFIED, key.GetState()) + assert.NotEqual(collect, webkey.State_STATE_REMOVED, key.GetState()) + assert.Equal(collect, config, key.GetKey()) + + if key.GetState() == webkey.State_STATE_ACTIVE { + gotActiveKeyID = key.GetId() + } + } + assert.NotEmpty(collect, gotActiveKeyID) + if expectActiveKeyID != "" { + assert.Equal(collect, expectActiveKeyID, gotActiveKeyID) + } + }, retryDuration, tick) +} diff --git a/internal/api/grpc/webkey/v2/server.go b/internal/api/grpc/webkey/v2/server.go new file mode 100644 index 0000000000..a62c29e2b9 --- /dev/null +++ b/internal/api/grpc/webkey/v2/server.go @@ -0,0 +1,51 @@ +package webkey + +import ( + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2/webkeyconnect" +) + +var _ webkeyconnect.WebKeyServiceHandler = (*Server)(nil) + +type Server struct { + command *command.Commands + query *query.Queries +} + +func CreateServer( + command *command.Commands, + query *query.Queries, +) *Server { + return &Server{ + command: command, + query: query, + } +} + +func (s *Server) AppName() string { + return webkey.WebKeyService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return webkey.WebKeyService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return webkey.WebKeyService_AuthMethods +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return webkeyconnect.NewWebKeyServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return webkey.File_zitadel_webkey_v2_webkey_service_proto +} diff --git a/internal/api/grpc/webkey/v2/webkey.go b/internal/api/grpc/webkey/v2/webkey.go new file mode 100644 index 0000000000..d1a10a31d0 --- /dev/null +++ b/internal/api/grpc/webkey/v2/webkey.go @@ -0,0 +1,72 @@ +package webkey + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +func (s *Server) CreateWebKey(ctx context.Context, req *connect.Request[webkey.CreateWebKeyRequest]) (_ *connect.Response[webkey.CreateWebKeyResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req.Msg)) + if err != nil { + return nil, err + } + + return connect.NewResponse(&webkey.CreateWebKeyResponse{ + Id: webKey.KeyID, + CreationDate: timestamppb.New(webKey.ObjectDetails.EventDate), + }), nil +} + +func (s *Server) ActivateWebKey(ctx context.Context, req *connect.Request[webkey.ActivateWebKeyRequest]) (_ *connect.Response[webkey.ActivateWebKeyResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + details, err := s.command.ActivateWebKey(ctx, req.Msg.GetId()) + if err != nil { + return nil, err + } + + return connect.NewResponse(&webkey.ActivateWebKeyResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) DeleteWebKey(ctx context.Context, req *connect.Request[webkey.DeleteWebKeyRequest]) (_ *connect.Response[webkey.DeleteWebKeyResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + deletedAt, err := s.command.DeleteWebKey(ctx, req.Msg.GetId()) + if err != nil { + return nil, err + } + + var deletionDate *timestamppb.Timestamp + if !deletedAt.IsZero() { + deletionDate = timestamppb.New(deletedAt) + } + return connect.NewResponse(&webkey.DeleteWebKeyResponse{ + DeletionDate: deletionDate, + }), nil +} + +func (s *Server) ListWebKeys(ctx context.Context, _ *connect.Request[webkey.ListWebKeysRequest]) (_ *connect.Response[webkey.ListWebKeysResponse], err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + list, err := s.query.ListWebKeys(ctx) + if err != nil { + return nil, err + } + + return connect.NewResponse(&webkey.ListWebKeysResponse{ + WebKeys: webKeyDetailsListToPb(list), + }), nil +} diff --git a/internal/api/grpc/webkey/v2/webkey_converter.go b/internal/api/grpc/webkey/v2/webkey_converter.go new file mode 100644 index 0000000000..7ee7fbce05 --- /dev/null +++ b/internal/api/grpc/webkey/v2/webkey_converter.go @@ -0,0 +1,170 @@ +package webkey + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +func createWebKeyRequestToConfig(req *webkey.CreateWebKeyRequest) crypto.WebKeyConfig { + switch config := req.GetKey().(type) { + case *webkey.CreateWebKeyRequest_Rsa: + return rsaToCrypto(config.Rsa) + case *webkey.CreateWebKeyRequest_Ecdsa: + return ecdsaToCrypto(config.Ecdsa) + case *webkey.CreateWebKeyRequest_Ed25519: + return new(crypto.WebKeyED25519Config) + default: + return rsaToCrypto(nil) + } +} + +func rsaToCrypto(config *webkey.RSA) *crypto.WebKeyRSAConfig { + out := new(crypto.WebKeyRSAConfig) + + switch config.GetBits() { + case webkey.RSABits_RSA_BITS_UNSPECIFIED: + out.Bits = crypto.RSABits2048 + case webkey.RSABits_RSA_BITS_2048: + out.Bits = crypto.RSABits2048 + case webkey.RSABits_RSA_BITS_3072: + out.Bits = crypto.RSABits3072 + case webkey.RSABits_RSA_BITS_4096: + out.Bits = crypto.RSABits4096 + default: + out.Bits = crypto.RSABits2048 + } + + switch config.GetHasher() { + case webkey.RSAHasher_RSA_HASHER_UNSPECIFIED: + out.Hasher = crypto.RSAHasherSHA256 + case webkey.RSAHasher_RSA_HASHER_SHA256: + out.Hasher = crypto.RSAHasherSHA256 + case webkey.RSAHasher_RSA_HASHER_SHA384: + out.Hasher = crypto.RSAHasherSHA384 + case webkey.RSAHasher_RSA_HASHER_SHA512: + out.Hasher = crypto.RSAHasherSHA512 + default: + out.Hasher = crypto.RSAHasherSHA256 + } + + return out +} + +func ecdsaToCrypto(config *webkey.ECDSA) *crypto.WebKeyECDSAConfig { + out := new(crypto.WebKeyECDSAConfig) + + switch config.GetCurve() { + case webkey.ECDSACurve_ECDSA_CURVE_UNSPECIFIED: + out.Curve = crypto.EllipticCurveP256 + case webkey.ECDSACurve_ECDSA_CURVE_P256: + out.Curve = crypto.EllipticCurveP256 + case webkey.ECDSACurve_ECDSA_CURVE_P384: + out.Curve = crypto.EllipticCurveP384 + case webkey.ECDSACurve_ECDSA_CURVE_P512: + out.Curve = crypto.EllipticCurveP512 + default: + out.Curve = crypto.EllipticCurveP256 + } + + return out +} + +func webKeyDetailsListToPb(list []query.WebKeyDetails) []*webkey.WebKey { + out := make([]*webkey.WebKey, len(list)) + for i := range list { + out[i] = webKeyDetailsToPb(&list[i]) + } + return out +} + +func webKeyDetailsToPb(details *query.WebKeyDetails) *webkey.WebKey { + out := &webkey.WebKey{ + Id: details.KeyID, + CreationDate: timestamppb.New(details.CreationDate), + ChangeDate: timestamppb.New(details.ChangeDate), + State: webKeyStateToPb(details.State), + } + + switch config := details.Config.(type) { + case *crypto.WebKeyRSAConfig: + out.Key = &webkey.WebKey_Rsa{ + Rsa: webKeyRSAConfigToPb(config), + } + case *crypto.WebKeyECDSAConfig: + out.Key = &webkey.WebKey_Ecdsa{ + Ecdsa: webKeyECDSAConfigToPb(config), + } + case *crypto.WebKeyED25519Config: + out.Key = &webkey.WebKey_Ed25519{ + Ed25519: new(webkey.ED25519), + } + } + + return out +} + +func webKeyStateToPb(state domain.WebKeyState) webkey.State { + switch state { + case domain.WebKeyStateUnspecified: + return webkey.State_STATE_UNSPECIFIED + case domain.WebKeyStateInitial: + return webkey.State_STATE_INITIAL + case domain.WebKeyStateActive: + return webkey.State_STATE_ACTIVE + case domain.WebKeyStateInactive: + return webkey.State_STATE_INACTIVE + case domain.WebKeyStateRemoved: + return webkey.State_STATE_REMOVED + default: + return webkey.State_STATE_UNSPECIFIED + } +} + +func webKeyRSAConfigToPb(config *crypto.WebKeyRSAConfig) *webkey.RSA { + out := new(webkey.RSA) + + switch config.Bits { + case crypto.RSABitsUnspecified: + out.Bits = webkey.RSABits_RSA_BITS_UNSPECIFIED + case crypto.RSABits2048: + out.Bits = webkey.RSABits_RSA_BITS_2048 + case crypto.RSABits3072: + out.Bits = webkey.RSABits_RSA_BITS_3072 + case crypto.RSABits4096: + out.Bits = webkey.RSABits_RSA_BITS_4096 + } + + switch config.Hasher { + case crypto.RSAHasherUnspecified: + out.Hasher = webkey.RSAHasher_RSA_HASHER_UNSPECIFIED + case crypto.RSAHasherSHA256: + out.Hasher = webkey.RSAHasher_RSA_HASHER_SHA256 + case crypto.RSAHasherSHA384: + out.Hasher = webkey.RSAHasher_RSA_HASHER_SHA384 + case crypto.RSAHasherSHA512: + out.Hasher = webkey.RSAHasher_RSA_HASHER_SHA512 + } + + return out +} + +func webKeyECDSAConfigToPb(config *crypto.WebKeyECDSAConfig) *webkey.ECDSA { + out := new(webkey.ECDSA) + + switch config.Curve { + case crypto.EllipticCurveUnspecified: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_UNSPECIFIED + case crypto.EllipticCurveP256: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_P256 + case crypto.EllipticCurveP384: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_P384 + case crypto.EllipticCurveP512: + out.Curve = webkey.ECDSACurve_ECDSA_CURVE_P512 + } + + return out +} diff --git a/internal/api/grpc/webkey/v2/webkey_converter_test.go b/internal/api/grpc/webkey/v2/webkey_converter_test.go new file mode 100644 index 0000000000..e7387d96ad --- /dev/null +++ b/internal/api/grpc/webkey/v2/webkey_converter_test.go @@ -0,0 +1,494 @@ +package webkey + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" +) + +func Test_createWebKeyRequestToConfig(t *testing.T) { + type args struct { + req *webkey.CreateWebKeyRequest + } + tests := []struct { + name string + args args + want crypto.WebKeyConfig + }{ + { + name: "RSA", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + name: "ECDSA", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Ecdsa{ + Ecdsa: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }, + }, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + { + name: "ED25519", + args: args{&webkey.CreateWebKeyRequest{ + Key: &webkey.CreateWebKeyRequest_Ed25519{ + Ed25519: &webkey.ED25519{}, + }, + }}, + want: &crypto.WebKeyED25519Config{}, + }, + { + name: "default", + args: args{&webkey.CreateWebKeyRequest{}}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := createWebKeyRequestToConfig(tt.args.req) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyRSAConfigToCrypto(t *testing.T) { + type args struct { + config *webkey.RSA + } + tests := []struct { + name string + args args + want *crypto.WebKeyRSAConfig + }{ + { + name: "unspecified", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_UNSPECIFIED, + Hasher: webkey.RSAHasher_RSA_HASHER_UNSPECIFIED, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + { + name: "2048, RSA256", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + { + name: "3072, RSA384", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + name: "4096, RSA512", + args: args{&webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_4096, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA512, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits4096, + Hasher: crypto.RSAHasherSHA512, + }, + }, + { + name: "invalid", + args: args{&webkey.RSA{ + Bits: 99, + Hasher: 99, + }}, + want: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rsaToCrypto(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyECDSAConfigToCrypto(t *testing.T) { + type args struct { + config *webkey.ECDSA + } + tests := []struct { + name string + args args + want *crypto.WebKeyECDSAConfig + }{ + { + name: "unspecified", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_UNSPECIFIED, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + { + name: "P256", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P256, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + { + name: "P384", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }, + { + name: "P512", + args: args{&webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P512, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP512, + }, + }, + { + name: "invalid", + args: args{&webkey.ECDSA{ + Curve: 99, + }}, + want: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ecdsaToCrypto(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyDetailsListToPb(t *testing.T) { + list := []query.WebKeyDetails{ + { + KeyID: "key1", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }, + { + KeyID: "key2", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyED25519Config{}, + }, + } + want := []*webkey.WebKey{ + { + Id: "key1", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + }, + { + Id: "key2", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Ed25519{ + Ed25519: &webkey.ED25519{}, + }, + }, + } + got := webKeyDetailsListToPb(list) + assert.Equal(t, want, got) +} + +func Test_webKeyDetailsToPb(t *testing.T) { + type args struct { + details *query.WebKeyDetails + } + tests := []struct { + name string + args args + want *webkey.WebKey + }{ + { + name: "RSA", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }, + }}, + want: &webkey.WebKey{ + Id: "keyID", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Rsa{ + Rsa: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + }, + }, + { + name: "ECDSA", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }, + }}, + want: &webkey.WebKey{ + Id: "keyID", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Ecdsa{ + Ecdsa: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }, + }, + }, + }, + { + name: "ED25519", + args: args{&query.WebKeyDetails{ + KeyID: "keyID", + CreationDate: time.Unix(123, 456), + ChangeDate: time.Unix(789, 0), + Sequence: 123, + State: domain.WebKeyStateActive, + Config: &crypto.WebKeyED25519Config{}, + }}, + want: &webkey.WebKey{ + Id: "keyID", + CreationDate: ×tamppb.Timestamp{Seconds: 123, Nanos: 456}, + ChangeDate: ×tamppb.Timestamp{Seconds: 789, Nanos: 0}, + State: webkey.State_STATE_ACTIVE, + Key: &webkey.WebKey_Ed25519{ + Ed25519: &webkey.ED25519{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyDetailsToPb(tt.args.details) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyStateToPb(t *testing.T) { + type args struct { + state domain.WebKeyState + } + tests := []struct { + name string + args args + want webkey.State + }{ + { + name: "unspecified", + args: args{domain.WebKeyStateUnspecified}, + want: webkey.State_STATE_UNSPECIFIED, + }, + { + name: "initial", + args: args{domain.WebKeyStateInitial}, + want: webkey.State_STATE_INITIAL, + }, + { + name: "active", + args: args{domain.WebKeyStateActive}, + want: webkey.State_STATE_ACTIVE, + }, + { + name: "inactive", + args: args{domain.WebKeyStateInactive}, + want: webkey.State_STATE_INACTIVE, + }, + { + name: "removed", + args: args{domain.WebKeyStateRemoved}, + want: webkey.State_STATE_REMOVED, + }, + { + name: "invalid", + args: args{99}, + want: webkey.State_STATE_UNSPECIFIED, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyStateToPb(tt.args.state) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyRSAConfigToPb(t *testing.T) { + type args struct { + config *crypto.WebKeyRSAConfig + } + tests := []struct { + name string + args args + want *webkey.RSA + }{ + { + name: "2048, RSA256", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + }}, + want: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_2048, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA256, + }, + }, + { + name: "3072, RSA384", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits3072, + Hasher: crypto.RSAHasherSHA384, + }}, + want: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_3072, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA384, + }, + }, + { + name: "4096, RSA512", + args: args{&crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits4096, + Hasher: crypto.RSAHasherSHA512, + }}, + want: &webkey.RSA{ + Bits: webkey.RSABits_RSA_BITS_4096, + Hasher: webkey.RSAHasher_RSA_HASHER_SHA512, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyRSAConfigToPb(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_webKeyECDSAConfigToPb(t *testing.T) { + type args struct { + config *crypto.WebKeyECDSAConfig + } + tests := []struct { + name string + args args + want *webkey.ECDSA + }{ + { + name: "P256", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP256, + }}, + want: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P256, + }, + }, + { + name: "P384", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP384, + }}, + want: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P384, + }, + }, + { + name: "P512", + args: args{&crypto.WebKeyECDSAConfig{ + Curve: crypto.EllipticCurveP512, + }}, + want: &webkey.ECDSA{ + Curve: webkey.ECDSACurve_ECDSA_CURVE_P512, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := webKeyECDSAConfigToPb(tt.args.config) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/api/grpc/webkey/v2beta/server.go b/internal/api/grpc/webkey/v2beta/server.go index 0d4ddb19c8..b000e98104 100644 --- a/internal/api/grpc/webkey/v2beta/server.go +++ b/internal/api/grpc/webkey/v2beta/server.go @@ -1,17 +1,22 @@ package webkey import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta/webkeyconnect" ) +var _ webkeyconnect.WebKeyServiceHandler = (*Server)(nil) + type Server struct { - webkey.UnimplementedWebKeyServiceServer command *command.Commands query *query.Queries } @@ -26,10 +31,6 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - webkey.RegisterWebKeyServiceServer(grpcServer, s) -} - func (s *Server) AppName() string { return webkey.WebKeyService_ServiceDesc.ServiceName } @@ -45,3 +46,11 @@ func (s *Server) AuthMethods() authz.MethodMapping { func (s *Server) RegisterGateway() server.RegisterGatewayFunc { return webkey.RegisterWebKeyServiceHandler } + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return webkeyconnect.NewWebKeyServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return webkey.File_zitadel_webkey_v2beta_webkey_service_proto +} diff --git a/internal/api/grpc/webkey/v2beta/webkey.go b/internal/api/grpc/webkey/v2beta/webkey.go index 469d6fc9a6..fa37cc32e3 100644 --- a/internal/api/grpc/webkey/v2beta/webkey.go +++ b/internal/api/grpc/webkey/v2beta/webkey.go @@ -3,46 +3,47 @@ package webkey import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/telemetry/tracing" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) -func (s *Server) CreateWebKey(ctx context.Context, req *webkey.CreateWebKeyRequest) (_ *webkey.CreateWebKeyResponse, err error) { +func (s *Server) CreateWebKey(ctx context.Context, req *connect.Request[webkey.CreateWebKeyRequest]) (_ *connect.Response[webkey.CreateWebKeyResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req)) + webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req.Msg)) if err != nil { return nil, err } - return &webkey.CreateWebKeyResponse{ + return connect.NewResponse(&webkey.CreateWebKeyResponse{ Id: webKey.KeyID, CreationDate: timestamppb.New(webKey.ObjectDetails.EventDate), - }, nil + }), nil } -func (s *Server) ActivateWebKey(ctx context.Context, req *webkey.ActivateWebKeyRequest) (_ *webkey.ActivateWebKeyResponse, err error) { +func (s *Server) ActivateWebKey(ctx context.Context, req *connect.Request[webkey.ActivateWebKeyRequest]) (_ *connect.Response[webkey.ActivateWebKeyResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - details, err := s.command.ActivateWebKey(ctx, req.GetId()) + details, err := s.command.ActivateWebKey(ctx, req.Msg.GetId()) if err != nil { return nil, err } - return &webkey.ActivateWebKeyResponse{ + return connect.NewResponse(&webkey.ActivateWebKeyResponse{ ChangeDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) DeleteWebKey(ctx context.Context, req *webkey.DeleteWebKeyRequest) (_ *webkey.DeleteWebKeyResponse, err error) { +func (s *Server) DeleteWebKey(ctx context.Context, req *connect.Request[webkey.DeleteWebKeyRequest]) (_ *connect.Response[webkey.DeleteWebKeyResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - deletedAt, err := s.command.DeleteWebKey(ctx, req.GetId()) + deletedAt, err := s.command.DeleteWebKey(ctx, req.Msg.GetId()) if err != nil { return nil, err } @@ -51,12 +52,12 @@ func (s *Server) DeleteWebKey(ctx context.Context, req *webkey.DeleteWebKeyReque if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &webkey.DeleteWebKeyResponse{ + return connect.NewResponse(&webkey.DeleteWebKeyResponse{ DeletionDate: deletionDate, - }, nil + }), nil } -func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) (_ *webkey.ListWebKeysResponse, err error) { +func (s *Server) ListWebKeys(ctx context.Context, _ *connect.Request[webkey.ListWebKeysRequest]) (_ *connect.Response[webkey.ListWebKeysResponse], err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -65,7 +66,7 @@ func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) return nil, err } - return &webkey.ListWebKeysResponse{ + return connect.NewResponse(&webkey.ListWebKeysResponse{ WebKeys: webKeyDetailsListToPb(list), - }, nil + }), nil } diff --git a/internal/integration/client.go b/internal/integration/client.go index 326d6fa8b4..c4f639b4b8 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -47,6 +47,7 @@ import ( user_pb "github.com/zitadel/zitadel/pkg/grpc/user" user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + webkey_v2 "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" webkey_v2beta "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) @@ -70,6 +71,7 @@ type Client struct { FeatureV2 feature.FeatureServiceClient UserSchemaV3 userschema_v3alpha.ZITADELUserSchemasClient WebKeyV2Beta webkey_v2beta.WebKeyServiceClient + WebKeyV2 webkey_v2.WebKeyServiceClient IDPv2 idp_pb.IdentityProviderServiceClient UserV3Alpha user_v3alpha.ZITADELUsersClient SAMLv2 saml_pb.SAMLServiceClient @@ -110,6 +112,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { FeatureV2: feature.NewFeatureServiceClient(cc), UserSchemaV3: userschema_v3alpha.NewZITADELUserSchemasClient(cc), WebKeyV2Beta: webkey_v2beta.NewWebKeyServiceClient(cc), + WebKeyV2: webkey_v2.NewWebKeyServiceClient(cc), IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), SAMLv2: saml_pb.NewSAMLServiceClient(cc), diff --git a/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl index adb71c42ff..0fb1c3e102 100644 --- a/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl +++ b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl @@ -5,6 +5,7 @@ package {{.GoPackageName}} import ( "github.com/zitadel/zitadel/internal/api/authz" {{if .AuthContext}}"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"{{end}} + {{if .AuthContext}}"github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware"{{end}} ) var {{.ServiceName}}_AuthMethods = authz.MethodMapping { @@ -23,6 +24,13 @@ func (r *{{ $m.Name }}) OrganizationFromRequest() *middleware.Organization { Domain: r{{$m.OrgMethod}}.GetOrgDomain(), } } + +func (r *{{ $m.Name }}) OrganizationFromRequestConnect() *connect_middleware.Organization { + return &connect_middleware.Organization{ + ID: r{{$m.OrgMethod}}.GetOrgId(), + Domain: r{{$m.OrgMethod}}.GetOrgDomain(), + } +} {{ end }} {{ range $resp := .CustomHTTPResponses}} diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index 09b5559fb9..9b65fec600 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -118,7 +118,7 @@ service SystemService { // Returns a list of ZITADEL instances // - // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/instance-service-list-instances.api.mdx) instead to list instances + // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-instances.api.mdx) instead to list instances rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { option (google.api.http) = { post: "/instances/_search" @@ -136,7 +136,7 @@ service SystemService { // Returns the detail of an instance // - // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/instance-service-get-instance.api.mdx) instead to get the details of the instance in context + // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-get-instance.api.mdx) instead to get the details of the instance in context rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { option (google.api.http) = { get: "/instances/{instance_id}"; @@ -171,7 +171,7 @@ service SystemService { // Updates name of an existing instance // - // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/instance-service-update-instance.api.mdx) instead to update the name of the instance in context + // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-update-instance.api.mdx) instead to update the name of the instance in context rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { option (google.api.http) = { put: "/instances/{instance_id}" @@ -203,7 +203,7 @@ service SystemService { // Removes an instance // This might take some time // - // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/instance-service-delete-instance.api.mdx) instead to delete an instance + // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-delete-instance.api.mdx) instead to delete an instance rpc RemoveInstance(RemoveInstanceRequest) returns (RemoveInstanceResponse) { option (google.api.http) = { delete: "/instances/{instance_id}" @@ -234,7 +234,7 @@ service SystemService { // Checks if a domain exists // - // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/instance-service-list-custom-domains.api.mdx) instead to check existence of an instance + // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-custom-domains.api.mdx) instead to check existence of an instance rpc ExistsDomain(ExistsDomainRequest) returns (ExistsDomainResponse) { option (google.api.http) = { post: "/domains/{domain}/_exists"; @@ -270,7 +270,7 @@ service SystemService { // Adds a domain to an instance // - // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context + // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context rpc AddDomain(AddDomainRequest) returns (AddDomainResponse) { option (google.api.http) = { post: "/instances/{instance_id}/domains"; @@ -288,7 +288,7 @@ service SystemService { // Removes the domain of an instance // - // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context + // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context rpc RemoveDomain(RemoveDomainRequest) returns (RemoveDomainResponse) { option (google.api.http) = { delete: "/instances/{instance_id}/domains/{domain}"; diff --git a/proto/zitadel/webkey/v2/key.proto b/proto/zitadel/webkey/v2/key.proto new file mode 100644 index 0000000000..4ec85fa168 --- /dev/null +++ b/proto/zitadel/webkey/v2/key.proto @@ -0,0 +1,109 @@ +syntax = "proto3"; + +package zitadel.webkey.v2; + +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/webkey/v2;webkey"; + +enum State { + STATE_UNSPECIFIED = 0; + // A newly created key is in the initial state and published to the public key endpoint. + STATE_INITIAL = 1; + // The active key is used to sign tokens. Only one key can be active at a time. + STATE_ACTIVE = 2; + // The inactive key is not used to sign tokens anymore, but still published to the public key endpoint. + STATE_INACTIVE = 3; + // The removed key is not used to sign tokens anymore and not published to the public key endpoint. + STATE_REMOVED = 4; +} + +message WebKey { + // The unique identifier of the key. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // The timestamp of the last change to the key (e.g. creation, activation, deactivation). + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // State of the key + State state = 4; + // Configured type of the key (either RSA, ECDSA or ED25519) + oneof key { + RSA rsa = 5; + ECDSA ecdsa = 6; + ED25519 ed25519 = 7; + } +} + +message RSA { + // Bit size of the RSA key. Default is 2048 bits. + RSABits bits = 1 [ + (validate.rules).enum = {defined_only: true, not_in: [0]}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "RSA_BITS_2048"; + } + ]; + // Signing algrithm used. Default is SHA256. + RSAHasher hasher = 2 [ + (validate.rules).enum = {defined_only: true, not_in: [0]}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "RSA_HASHER_SHA256"; + } + ]; +} + +enum RSABits { + RSA_BITS_UNSPECIFIED = 0; + // 2048 bit RSA key + RSA_BITS_2048 = 1; + // 3072 bit RSA key + RSA_BITS_3072 = 2; + // 4096 bit RSA key + RSA_BITS_4096 = 3; +} + +enum RSAHasher { + RSA_HASHER_UNSPECIFIED = 0; + // SHA256 hashing algorithm resulting in the RS256 algorithm header + RSA_HASHER_SHA256 = 1; + // SHA384 hashing algorithm resulting in the RS384 algorithm header + RSA_HASHER_SHA384 = 2; + // SHA512 hashing algorithm resulting in the RS512 algorithm header + RSA_HASHER_SHA512 = 3; +} + +message ECDSA { + // Curve of the ECDSA key. Default is P-256. + ECDSACurve curve = 1 [ + (validate.rules).enum = {defined_only: true, not_in: [0]}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "ECDSA_CURVE_P256"; + } + ]; +} + +enum ECDSACurve { + ECDSA_CURVE_UNSPECIFIED = 0; + // NIST P-256 curve resulting in the ES256 algorithm header + ECDSA_CURVE_P256 = 1; + // NIST P-384 curve resulting in the ES384 algorithm header + ECDSA_CURVE_P384 = 2; + // NIST P-512 curve resulting in the ES512 algorithm header + ECDSA_CURVE_P512 = 3; +} + +message ED25519 {} diff --git a/proto/zitadel/webkey/v2/webkey_service.proto b/proto/zitadel/webkey/v2/webkey_service.proto new file mode 100644 index 0000000000..f29f291c38 --- /dev/null +++ b/proto/zitadel/webkey/v2/webkey_service.proto @@ -0,0 +1,335 @@ +syntax = "proto3"; + +package zitadel.webkey.v2; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/timestamp.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/webkey/v2/key.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/webkey/v2;webkey"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Web Key Service"; + version: "2.0"; + description: "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n\nThe public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n\nPlease make sure to enable the `web_key` feature flag on your instance to use this service."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + produces: "application/json"; + + consumes: "application/grpc"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage web keys for OIDC token signing and validation. +// The service provides methods to create, activate, delete and list web keys. +// The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys. +// +// Please make sure to enable the `web_key` feature flag on your instance to use this service. +service WebKeyService { + // Create Web Key + // + // Generate a private and public key pair. The private key can be used to sign OIDC tokens after activation. + // The public key can be used to validate OIDC tokens. + // The newly created key will have the state `STATE_INITIAL` and is published to the public key endpoint. + // Note that the JWKs OIDC endpoint returns a cacheable response. + // + // If no key type is provided, a RSA key pair with 2048 bits and SHA256 hashing will be created. + // + // Required permission: + // - `iam.web_key.write` + // + // Required feature flag: + // - `web_key` + rpc CreateWebKey(CreateWebKeyRequest) returns (CreateWebKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.write" + } + }; + } + + // Activate Web Key + // + // Switch the active signing web key. The previously active key will be deactivated. + // Note that the JWKs OIDC endpoint returns a cacheable response. + // Therefore it is not advised to activate a key that has been created within the cache duration (default is 5min), + // as the public key may not have been propagated to caches and clients yet. + // + // Required permission: + // - `iam.web_key.write` + // + // Required feature flag: + // - `web_key` + rpc ActivateWebKey(ActivateWebKeyRequest) returns (ActivateWebKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.write" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Web key activated successfully."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `web_key` is not enabled."; + } + }; + responses: { + key: "404" + value: { + description: "The web key to active does not exist."; + } + }; + }; + } + + // Delete Web Key + // + // Delete a web key pair. Only inactive keys can be deleted. Once a key is deleted, + // any tokens signed by this key will be invalid. + // Note that the JWKs OIDC endpoint returns a cacheable response. + // In case the web key is not found, the request will return a successful response as + // the desired state is already achieved. + // You can check the change date in the response to verify if the web key was deleted during the request. + // + // Required permission: + // - `iam.web_key.delete` + // + // Required feature flag: + // - `web_key` + rpc DeleteWebKey(DeleteWebKeyRequest) returns (DeleteWebKeyResponse) { + option (google.api.http) = { + delete: "/v2/web_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.delete" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "Web key deleted successfully."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `web_key` is not enabled or the web key is currently active."; + } + }; + }; + } + + // List Web Keys + // + // List all web keys and their states. + // + // Required permission: + // - `iam.web_key.read` + // + // Required feature flag: + // - `web_key` + rpc ListWebKeys(ListWebKeysRequest) returns (ListWebKeysResponse) { + option (google.api.http) = { + get: "/v2/web_keys" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.web_key.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "List of all web keys."; + } + }; + responses: { + key: "400" + value: { + description: "The feature flag `web_key` is not enabled."; + } + }; + }; + } +} + +message CreateWebKeyRequest { + // The key type to create (RSA, ECDSA, ED25519). + // If no key type is provided, a RSA key pair with 2048 bits and SHA256 hashing will be created. + oneof key { + // Create a RSA key pair and specify the bit size and hashing algorithm. + // If no bits and hasher are provided, a RSA key pair with 2048 bits and SHA256 hashing will be created. + RSA rsa = 1; + // Create a ECDSA key pair and specify the curve. + // If no curve is provided, a ECDSA key pair with P-256 curve will be created. + ECDSA ecdsa = 2; + // Create a ED25519 key pair. + ED25519 ed25519 = 3; + } + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { + example: "{\"rsa\":{\"bits\":\"RSA_BITS_2048\",\"hasher\":\"RSA_HASHER_SHA256\"}}"; + }; +} + +message CreateWebKeyResponse { + // The unique identifier of the newly created key. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // The timestamp of the key creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ActivateWebKeyRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message ActivateWebKeyResponse { + // The timestamp of the activation of the key. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeleteWebKeyRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1, + max_length: 200, + example: "\"69629026806489455\""; + } + ]; +} + +message DeleteWebKeyResponse { + // The timestamp of the deletion of the key. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might be empty. + google.protobuf.Timestamp deletion_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListWebKeysRequest {} + +message ListWebKeysResponse { + repeated WebKey web_keys = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[{\"id\":\"69629012906488334\",\"creationDate\":\"2024-12-18T07:50:47.492Z\",\"changeDate\":\"2024-12-18T08:04:47.492Z\",\"state\":\"STATE_ACTIVE\",\"rsa\":{\"bits\":\"RSA_BITS_2048\",\"hasher\":\"RSA_HASHER_SHA256\"}},{\"id\":\"69629012909346200\",\"creationDate\":\"2025-01-18T12:05:47.492Z\",\"state\":\"STATE_INITIAL\",\"ecdsa\":{\"curve\":\"ECDSA_CURVE_P256\"}}]"; + } + ]; +} \ No newline at end of file

{{ 'IDP.STATE' | translate }}

From 85e3b7449c417a238e12bac2b312ce4d58905804 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:46:10 +0200 Subject: [PATCH 088/123] fix: correct permissions for projects on v2 api (#9973) # Which Problems Are Solved Permission checks in project v2beta API did not cover projects and granted projects correctly. # How the Problems Are Solved Add permission checks v1 correctly to the list queries, add correct permission checks v2 for projects. # Additional Changes Correct Pre-Checks for project grants that the right resource owner is used. # Additional Context Permission checks v2 for project grants is still outstanding under #9972. --- internal/api/grpc/management/project_grant.go | 4 +- .../v2beta/integration/project_grant_test.go | 65 ++- .../v2beta/integration/project_test.go | 65 +++ .../project/v2beta/integration/query_test.go | 182 ++++++- .../api/grpc/project/v2beta/project_grant.go | 10 +- internal/command/permission_checks.go | 18 +- internal/command/project_grant.go | 116 +++-- internal/command/project_grant_model.go | 62 +-- internal/command/project_grant_test.go | 444 +++++++++++++++++- internal/command/project_old.go | 2 +- internal/domain/roles.go | 1 + internal/integration/client.go | 18 + internal/query/project.go | 48 +- internal/query/project_grant.go | 15 +- internal/query/project_role.go | 2 +- 15 files changed, 950 insertions(+), 102 deletions(-) diff --git a/internal/api/grpc/management/project_grant.go b/internal/api/grpc/management/project_grant.go index e9313c1327..d84375818d 100644 --- a/internal/api/grpc/management/project_grant.go +++ b/internal/api/grpc/management/project_grant.go @@ -109,7 +109,7 @@ func (s *Server) UpdateProjectGrant(ctx context.Context, req *mgmt_pb.UpdateProj } func (s *Server) DeactivateProjectGrant(ctx context.Context, req *mgmt_pb.DeactivateProjectGrantRequest) (*mgmt_pb.DeactivateProjectGrantResponse, error) { - details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, req.GrantId, authz.GetCtxData(ctx).OrgID) + details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, req.GrantId, "", authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -119,7 +119,7 @@ func (s *Server) DeactivateProjectGrant(ctx context.Context, req *mgmt_pb.Deacti } func (s *Server) ReactivateProjectGrant(ctx context.Context, req *mgmt_pb.ReactivateProjectGrantRequest) (*mgmt_pb.ReactivateProjectGrantResponse, error) { - details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, req.GrantId, authz.GetCtxData(ctx).OrgID) + details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, req.GrantId, "", authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/project/v2beta/integration/project_grant_test.go b/internal/api/grpc/project/v2beta/integration/project_grant_test.go index 8500f24d56..34fa10e3de 100644 --- a/internal/api/grpc/project/v2beta/integration/project_grant_test.go +++ b/internal/api/grpc/project/v2beta/integration/project_grant_test.go @@ -169,6 +169,12 @@ func TestServer_CreateProjectGrant_Permission(t *testing.T) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + userResp := instance.CreateMachineUser(iamOwnerCtx) + patResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userResp.GetUserId()) + projectResp := createProject(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patResp.Token) + type want struct { creationDate bool } @@ -206,6 +212,33 @@ func TestServer_CreateProjectGrant_Permission(t *testing.T) { req: &project.CreateProjectGrantRequest{}, wantErr: true, }, + { + name: "project owner, other project", + ctx: projectOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false) + grantedOrgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + request.ProjectId = projectResp.GetId() + request.GrantedOrganizationId = grantedOrgResp.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + wantErr: true, + }, + { + name: "project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *project.CreateProjectGrantRequest) { + request.ProjectId = projectResp.GetId() + + grantedOrg := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + request.GrantedOrganizationId = grantedOrg.GetOrganizationId() + }, + req: &project.CreateProjectGrantRequest{}, + want: want{ + creationDate: true, + }, + }, { name: "organization owner, other org", ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), @@ -405,6 +438,13 @@ func TestServer_UpdateProjectGrant_Permission(t *testing.T) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + userResp := instance.CreateMachineUser(iamOwnerCtx) + patResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userResp.GetUserId()) + projectID := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false).GetId() + instance.CreateProjectGrant(iamOwnerCtx, t, projectID, orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectID, orgResp.GetOrganizationId(), userResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patResp.Token) + type args struct { ctx context.Context req *project.UpdateProjectGrantRequest @@ -458,6 +498,25 @@ func TestServer_UpdateProjectGrant_Permission(t *testing.T) { }, wantErr: true, }, + { + name: "project grant owner, no permission", + prepare: func(request *project.UpdateProjectGrantRequest) { + roles := []string{gofakeit.Animal(), gofakeit.Animal(), gofakeit.Animal()} + request.ProjectId = projectID + request.GrantedOrganizationId = orgResp.GetOrganizationId() + + for _, role := range roles { + instance.AddProjectRole(iamOwnerCtx, t, projectID, role, role, "") + } + + request.RoleKeys = roles + }, + args: args{ + ctx: projectGrantOwnerCtx, + req: &project.UpdateProjectGrantRequest{}, + }, + wantErr: true, + }, { name: "organization owner, other org", prepare: func(request *project.UpdateProjectGrantRequest) { @@ -598,7 +657,7 @@ func TestServer_DeleteProjectGrant(t *testing.T) { ProjectId: "notexisting", GrantedOrganizationId: "notexisting", }, - wantErr: true, + wantDeletionDate: false, }, { name: "delete", @@ -650,8 +709,8 @@ func TestServer_DeleteProjectGrant(t *testing.T) { instance.DeleteProjectGrant(iamOwnerCtx, t, projectResp.GetId(), grantedOrg.GetOrganizationId()) return creationDate, time.Now().UTC() }, - req: &project.DeleteProjectGrantRequest{}, - wantErr: true, + req: &project.DeleteProjectGrantRequest{}, + wantDeletionDate: true, }, } for _, tt := range tests { diff --git a/internal/api/grpc/project/v2beta/integration/project_test.go b/internal/api/grpc/project/v2beta/integration/project_test.go index 6c0a5c96f6..5412f6eb58 100644 --- a/internal/api/grpc/project/v2beta/integration/project_test.go +++ b/internal/api/grpc/project/v2beta/integration/project_test.go @@ -300,6 +300,12 @@ func TestServer_UpdateProject_Permission(t *testing.T) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + userResp := instance.CreateMachineUser(iamOwnerCtx) + patResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userResp.GetUserId()) + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + instance.CreateProjectMembership(t, iamOwnerCtx, projectID, userResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patResp.Token) + type args struct { ctx context.Context req *project.UpdateProjectRequest @@ -343,6 +349,36 @@ func TestServer_UpdateProject_Permission(t *testing.T) { }, wantErr: true, }, + { + name: "project owner, no permission", + prepare: func(request *project.UpdateProjectRequest) { + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + }, + args: args{ + ctx: projectOwnerCtx, + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.AppName()), + }, + }, + wantErr: true, + }, + { + name: " roject owner, ok", + prepare: func(request *project.UpdateProjectRequest) { + request.Id = projectID + }, + args: args{ + ctx: projectOwnerCtx, + req: &project.UpdateProjectRequest{ + Name: gu.Ptr(gofakeit.AppName()), + }, + }, + want: want{ + change: true, + changeDate: true, + }, + }, { name: "missing permission, other organization", prepare: func(request *project.UpdateProjectRequest) { @@ -499,6 +535,12 @@ func TestServer_DeleteProject_Permission(t *testing.T) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + userResp := instance.CreateMachineUser(iamOwnerCtx) + patResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userResp.GetUserId()) + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + instance.CreateProjectMembership(t, iamOwnerCtx, projectID, userResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patResp.Token) + tests := []struct { name string ctx context.Context @@ -531,6 +573,29 @@ func TestServer_DeleteProject_Permission(t *testing.T) { req: &project.DeleteProjectRequest{}, wantErr: true, }, + { + name: "project owner, no permission", + ctx: projectOwnerCtx, + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + projectID := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), gofakeit.AppName(), false, false).GetId() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantErr: true, + }, + { + name: "project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *project.DeleteProjectRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + request.Id = projectID + return creationDate, time.Time{} + }, + req: &project.DeleteProjectRequest{}, + wantDeletionDate: true, + }, { name: "organization owner, other org", ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), diff --git a/internal/api/grpc/project/v2beta/integration/query_test.go b/internal/api/grpc/project/v2beta/integration/query_test.go index 517f103628..fc153f0130 100644 --- a/internal/api/grpc/project/v2beta/integration/query_test.go +++ b/internal/api/grpc/project/v2beta/integration/query_test.go @@ -148,6 +148,14 @@ func TestServer_GetProject(t *testing.T) { func TestServer_ListProjects(t *testing.T) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + userResp := instance.CreateMachineUser(iamOwnerCtx) + patResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userResp.GetUserId()) + projectResp := createProject(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetUserId()) + grantedProjectResp := createGrantedProject(iamOwnerCtx, instance, t, projectResp) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patResp.Token) + type args struct { ctx context.Context dep func(*project.ListProjectsRequest, *project.ListProjectsResponse) @@ -370,6 +378,39 @@ func TestServer_ListProjects(t *testing.T) { }, }, }, + { + name: "list multiple id, limited permissions, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + resp1 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, false) + resp2 := createProject(iamOwnerCtx, instance, t, orgID, true, false) + resp3 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, true) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId(), projectResp.GetId()}, + }, + } + response.Projects[0] = grantedProjectResp + response.Projects[1] = projectResp + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 5, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + {}, + }, + }, + }, { name: "list project and granted projects", args: args{ @@ -462,6 +503,51 @@ func TestServer_ListProjects(t *testing.T) { }, }, }, + { + name: "list granted project, project id", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instance.DefaultOrg.GetId() + + orgName := gofakeit.AppName() + projectName := gofakeit.AppName() + orgResp := instance.CreateOrganization(iamOwnerCtx, orgName, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), projectName, true, true) + projectGrantResp := instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgID) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ProjectIds: []string{projectResp.GetId()}}, + } + response.Projects[0] = &project.Project{ + Id: projectResp.GetId(), + Name: projectName, + OrganizationId: orgResp.GetOrganizationId(), + CreationDate: projectGrantResp.GetCreationDate(), + ChangeDate: projectGrantResp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: true, + AuthorizationRequired: true, + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + GrantedOrganizationId: gu.Ptr(orgID), + GrantedOrganizationName: gu.Ptr(instance.DefaultOrg.GetName()), + GrantedState: 1, + } + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Projects: []*project.Project{ + {}, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -791,6 +877,53 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { }, }, }, + // TODO: correct when permission check is added for project grants https://github.com/zitadel/zitadel/issues/9972 + { + name: "list granted project, project id", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *project.ListProjectsRequest, response *project.ListProjectsResponse) { + orgID := instancePermissionV2.DefaultOrg.GetId() + + orgName := gofakeit.AppName() + projectName := gofakeit.AppName() + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, orgName, gofakeit.Email()) + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), projectName, true, true) + // projectGrantResp := + instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgID) + request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ProjectIds: []string{projectResp.GetId()}}, + } + /* + response.Projects[0] = &project.Project{ + Id: projectResp.GetId(), + Name: projectName, + OrganizationId: orgResp.GetOrganizationId(), + CreationDate: projectGrantResp.GetCreationDate(), + ChangeDate: projectGrantResp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: true, + AuthorizationRequired: true, + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + GrantedOrganizationId: gu.Ptr(orgID), + GrantedOrganizationName: gu.Ptr(instancePermissionV2.DefaultOrg.GetName()), + GrantedState: 1, + } + */ + }, + req: &project.ListProjectsRequest{ + Filters: []*project.ProjectSearchFilter{{}}, + }, + }, + want: &project.ListProjectsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Projects: []*project.Project{}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -865,6 +998,14 @@ func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationRes func TestServer_ListProjectGrants(t *testing.T) { iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + userResp := instance.CreateMachineUser(iamOwnerCtx) + patResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userResp.GetUserId()) + projectResp := createProject(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), false, false) + projectGrantResp := createProjectGrant(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectResp.GetName()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), projectGrantResp.GetGrantedOrganizationId(), userResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patResp.Token) + type args struct { ctx context.Context dep func(*project.ListProjectGrantsRequest, *project.ListProjectGrantsResponse) @@ -1071,7 +1212,46 @@ func TestServer_ListProjectGrants(t *testing.T) { {}, }, }, - }, { + }, + { + name: "list multiple id, limited permissions, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *project.ListProjectGrantsRequest, response *project.ListProjectGrantsResponse) { + name1 := gofakeit.AppName() + name2 := gofakeit.AppName() + name3 := gofakeit.AppName() + orgID := instance.DefaultOrg.GetId() + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + project1Resp := instance.CreateProject(iamOwnerCtx, t, orgID, name1, false, false) + project2Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) + project3Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) + request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ + InProjectIdsFilter: &project.InProjectIDsFilter{ + ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId(), projectResp.GetId()}, + }, + } + + createProjectGrant(iamOwnerCtx, instance, t, orgID, project1Resp.GetId(), name1) + createProjectGrant(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), project2Resp.GetId(), name2) + createProjectGrant(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), project3Resp.GetId(), name3) + response.ProjectGrants[0] = projectGrantResp + }, + req: &project.ListProjectGrantsRequest{ + Filters: []*project.ProjectGrantSearchFilter{{}}, + }, + }, + want: &project.ListProjectGrantsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + ProjectGrants: []*project.ProjectGrant{ + {}, + }, + }, + }, + { name: "list single id with role", args: args{ ctx: iamOwnerCtx, diff --git a/internal/api/grpc/project/v2beta/project_grant.go b/internal/api/grpc/project/v2beta/project_grant.go index c1c20d9cbc..6c3b195c66 100644 --- a/internal/api/grpc/project/v2beta/project_grant.go +++ b/internal/api/grpc/project/v2beta/project_grant.go @@ -56,13 +56,13 @@ func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *com ObjectRoot: models.ObjectRoot{ AggregateID: req.ProjectId, }, - GrantID: req.GrantedOrganizationId, - RoleKeys: req.RoleKeys, + GrantedOrgID: req.GrantedOrganizationId, + RoleKeys: req.RoleKeys, } } func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.DeactivateProjectGrantRequest) (*project_pb.DeactivateProjectGrantResponse, error) { - details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId, "") + details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") if err != nil { return nil, err } @@ -76,7 +76,7 @@ func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.Dea } func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.ActivateProjectGrantRequest) (*project_pb.ActivateProjectGrantResponse, error) { - details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId, "") + details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") if err != nil { return nil, err } @@ -94,7 +94,7 @@ func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteP if err != nil { return nil, err } - details, err := s.command.RemoveProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId, "", userGrantIDs...) + details, err := s.command.DeleteProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "", userGrantIDs...) if err != nil { return nil, err } diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 253b6ee72a..6bfeaae219 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -68,6 +68,20 @@ func (c *Commands) checkPermissionUpdateProject(ctx context.Context, resourceOwn return c.newPermissionCheck(ctx, domain.PermissionProjectWrite, project.AggregateType)(resourceOwner, projectID) } -func (c *Commands) checkPermissionWriteProjectGrant(ctx context.Context, resourceOwner, projectGrantID string) error { - return c.newPermissionCheck(ctx, domain.PermissionProjectGrantWrite, project.AggregateType)(resourceOwner, projectGrantID) +func (c *Commands) checkPermissionUpdateProjectGrant(ctx context.Context, resourceOwner, projectID, projectGrantID string) (err error) { + if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantWrite, project.AggregateType)(resourceOwner, projectGrantID); err != nil { + if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantWrite, project.AggregateType)(resourceOwner, projectID); err != nil { + return err + } + } + return nil +} + +func (c *Commands) checkPermissionDeleteProjectGrant(ctx context.Context, resourceOwner, projectID, projectGrantID string) (err error) { + if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantDelete, project.AggregateType)(resourceOwner, projectGrantID); err != nil { + if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantDelete, project.AggregateType)(resourceOwner, projectID); err != nil { + return err + } + } + return nil } diff --git a/internal/command/project_grant.go b/internal/command/project_grant.go index 763ea7ab67..b613974b7e 100644 --- a/internal/command/project_grant.go +++ b/internal/command/project_grant.go @@ -58,11 +58,11 @@ func (c *Commands) AddProjectGrant(ctx context.Context, grant *AddProjectGrant) if grant.ResourceOwner == "" { grant.ResourceOwner = projectResourceOwner } - if err := c.checkPermissionWriteProjectGrant(ctx, grant.ResourceOwner, grant.GrantID); err != nil { + if err := c.checkPermissionUpdateProjectGrant(ctx, grant.ResourceOwner, grant.AggregateID, grant.GrantID); err != nil { return nil, err } - wm := NewProjectGrantWriteModel(grant.GrantID, grant.AggregateID, grant.ResourceOwner) + wm := NewProjectGrantWriteModel(grant.GrantID, grant.GrantedOrgID, grant.AggregateID, grant.ResourceOwner) // error if provided resourceowner is not equal to the resourceowner of the project or the project grant is for the same organization if projectResourceOwner != wm.ResourceOwner || wm.ResourceOwner == grant.GrantedOrgID { return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-ckUpbvboAH", "Errors.Project.Grant.Invalid") @@ -83,19 +83,24 @@ func (c *Commands) AddProjectGrant(ctx context.Context, grant *AddProjectGrant) type ChangeProjectGrant struct { es_models.ObjectRoot - GrantID string - RoleKeys []string + GrantID string + GrantedOrgID string + RoleKeys []string } func (c *Commands) ChangeProjectGrant(ctx context.Context, grant *ChangeProjectGrant, cascadeUserGrantIDs ...string) (_ *domain.ObjectDetails, err error) { - if grant.GrantID == "" { + if grant.GrantID == "" && grant.GrantedOrgID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1j83s", "Errors.IDMissing") } - existingGrant, err := c.projectGrantWriteModelByID(ctx, grant.GrantID, grant.AggregateID, grant.ResourceOwner) + existingGrant, err := c.projectGrantWriteModelByID(ctx, grant.GrantID, grant.GrantedOrgID, grant.AggregateID, grant.ResourceOwner) if err != nil { return nil, err } - if err := c.checkPermissionWriteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + if !existingGrant.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") + } + + if err := c.checkPermissionUpdateProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.AggregateID, existingGrant.GrantID); err != nil { return nil, err } projectResourceOwner, err := c.checkProjectGrantPreCondition(ctx, existingGrant.AggregateID, existingGrant.GrantedOrgID, existingGrant.ResourceOwner, grant.RoleKeys) @@ -152,12 +157,12 @@ func (c *Commands) ChangeProjectGrant(ctx context.Context, grant *ChangeProjectG } func (c *Commands) removeRoleFromProjectGrant(ctx context.Context, projectAgg *eventstore.Aggregate, projectID, projectGrantID, roleKey string, cascade bool) (_ eventstore.Command, _ *ProjectGrantWriteModel, err error) { - existingProjectGrant, err := c.projectGrantWriteModelByID(ctx, projectGrantID, projectID, "") + existingProjectGrant, err := c.projectGrantWriteModelByID(ctx, projectGrantID, "", projectID, "") if err != nil { return nil, nil, err } - if existingProjectGrant.State == domain.ProjectGrantStateUnspecified || existingProjectGrant.State == domain.ProjectGrantStateRemoved { - return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.Project.Grant.NotFound") + if !existingProjectGrant.State.Exists() { + return nil, nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") } keyExists := false for i, key := range existingProjectGrant.RoleKeys { @@ -172,7 +177,7 @@ func (c *Commands) removeRoleFromProjectGrant(ctx context.Context, projectAgg *e if !keyExists { return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-5m8g9", "Errors.Project.Grant.RoleKeyNotFound") } - changedProjectGrant := NewProjectGrantWriteModel(projectGrantID, projectID, existingProjectGrant.ResourceOwner) + changedProjectGrant := NewProjectGrantWriteModel(projectGrantID, projectID, "", existingProjectGrant.ResourceOwner) if cascade { return project.NewGrantCascadeChangedEvent(ctx, projectAgg, projectGrantID, existingProjectGrant.RoleKeys), changedProjectGrant, nil @@ -181,8 +186,8 @@ func (c *Commands) removeRoleFromProjectGrant(ctx context.Context, projectAgg *e return project.NewGrantChangedEvent(ctx, projectAgg, projectGrantID, existingProjectGrant.RoleKeys), changedProjectGrant, nil } -func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantID, resourceOwner string) (details *domain.ObjectDetails, err error) { - if grantID == "" || projectID == "" { +func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantID, grantedOrgID, resourceOwner string) (details *domain.ObjectDetails, err error) { + if (grantID == "" && grantedOrgID == "") || projectID == "" { return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-p0s4V", "Errors.IDMissing") } @@ -191,10 +196,13 @@ func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantI return nil, err } - existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, projectID, resourceOwner) + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, grantedOrgID, projectID, resourceOwner) if err != nil { return details, err } + if !existingGrant.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") + } // error if provided resourceowner is not equal to the resourceowner of the project if projectResourceOwner != existingGrant.ResourceOwner { return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-0l10S9OmZV", "Errors.Project.Grant.Invalid") @@ -207,13 +215,13 @@ func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantI if existingGrant.State != domain.ProjectGrantStateActive { return details, zerrors.ThrowPreconditionFailed(nil, "PROJECT-47fu8", "Errors.Project.Grant.NotActive") } - if err := c.checkPermissionWriteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + if err := c.checkPermissionUpdateProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.AggregateID, existingGrant.GrantID); err != nil { return nil, err } pushedEvents, err := c.eventstore.Push(ctx, project.NewGrantDeactivateEvent(ctx, ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), - grantID, + existingGrant.GrantID, ), ) if err != nil { @@ -226,8 +234,8 @@ func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantI return writeModelToObjectDetails(&existingGrant.WriteModel), nil } -func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantID, resourceOwner string) (details *domain.ObjectDetails, err error) { - if grantID == "" || projectID == "" { +func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantID, grantedOrgID, resourceOwner string) (details *domain.ObjectDetails, err error) { + if (grantID == "" && grantedOrgID == "") || projectID == "" { return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-p0s4V", "Errors.IDMissing") } @@ -236,10 +244,13 @@ func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantI return nil, err } - existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, projectID, resourceOwner) + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, grantedOrgID, projectID, resourceOwner) if err != nil { return details, err } + if !existingGrant.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") + } // error if provided resourceowner is not equal to the resourceowner of the project if projectResourceOwner != existingGrant.ResourceOwner { return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-byscAarAST", "Errors.Project.Grant.Invalid") @@ -252,13 +263,13 @@ func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantI if existingGrant.State != domain.ProjectGrantStateInactive { return details, zerrors.ThrowPreconditionFailed(nil, "PROJECT-47fu8", "Errors.Project.Grant.NotInactive") } - if err := c.checkPermissionWriteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + if err := c.checkPermissionUpdateProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.AggregateID, existingGrant.GrantID); err != nil { return nil, err } pushedEvents, err := c.eventstore.Push(ctx, project.NewGrantReactivatedEvent(ctx, ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), - grantID, + existingGrant.GrantID, ), ) if err != nil { @@ -271,25 +282,25 @@ func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantI return writeModelToObjectDetails(&existingGrant.WriteModel), nil } +// Deprecated: use commands.DeleteProjectGrant func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, resourceOwner string, cascadeUserGrantIDs ...string) (details *domain.ObjectDetails, err error) { if grantID == "" || projectID == "" { return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-1m9fJ", "Errors.IDMissing") } - existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, projectID, resourceOwner) + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, "", projectID, resourceOwner) if err != nil { return details, err } - // return if project grant does not exist, or was removed already if !existingGrant.State.Exists() { - return writeModelToObjectDetails(&existingGrant.WriteModel), nil + return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") } - if err := c.checkPermissionDeleteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.GrantID); err != nil { + if err := c.checkPermissionDeleteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.AggregateID, existingGrant.GrantID); err != nil { return nil, err } events := make([]eventstore.Command, 0) events = append(events, project.NewGrantRemovedEvent(ctx, ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), - grantID, + existingGrant.GrantID, existingGrant.GrantedOrgID, ), ) @@ -297,7 +308,7 @@ func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, r for _, userGrantID := range cascadeUserGrantIDs { event, _, err := c.removeUserGrant(ctx, userGrantID, "", true) if err != nil { - logging.LogWithFields("COMMAND-3m8sG", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") + logging.WithFields("id", "COMMAND-3m8sG", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue } events = append(events, event) @@ -313,24 +324,57 @@ func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, r return writeModelToObjectDetails(&existingGrant.WriteModel), nil } -func (c *Commands) checkPermissionDeleteProjectGrant(ctx context.Context, resourceOwner, projectGrantID string) error { - return c.checkPermission(ctx, domain.PermissionProjectGrantDelete, resourceOwner, projectGrantID) +func (c *Commands) DeleteProjectGrant(ctx context.Context, projectID, grantID, grantedOrgID, resourceOwner string, cascadeUserGrantIDs ...string) (details *domain.ObjectDetails, err error) { + if (grantID == "" && grantedOrgID == "") || projectID == "" { + return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-1m9fJ", "Errors.IDMissing") + } + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, grantedOrgID, projectID, resourceOwner) + if err != nil { + return details, err + } + // return if project grant does not exist, or was removed already + if !existingGrant.State.Exists() { + return writeModelToObjectDetails(&existingGrant.WriteModel), nil + } + if err := c.checkPermissionDeleteProjectGrant(ctx, existingGrant.ResourceOwner, existingGrant.AggregateID, existingGrant.GrantID); err != nil { + return nil, err + } + events := make([]eventstore.Command, 0) + events = append(events, project.NewGrantRemovedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), + existingGrant.GrantID, + existingGrant.GrantedOrgID, + ), + ) + + for _, userGrantID := range cascadeUserGrantIDs { + event, _, err := c.removeUserGrant(ctx, userGrantID, "", true) + if err != nil { + logging.WithFields("id", "COMMAND-3m8sG", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") + continue + } + events = append(events, event) + } + pushedEvents, err := c.eventstore.Push(ctx, events...) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingGrant, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&existingGrant.WriteModel), nil } -func (c *Commands) projectGrantWriteModelByID(ctx context.Context, grantID, projectID, resourceOwner string) (member *ProjectGrantWriteModel, err error) { +func (c *Commands) projectGrantWriteModelByID(ctx context.Context, grantID, grantedOrgID, projectID, resourceOwner string) (member *ProjectGrantWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel := NewProjectGrantWriteModel(grantID, projectID, resourceOwner) + writeModel := NewProjectGrantWriteModel(grantID, grantedOrgID, projectID, resourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err } - - if writeModel.State == domain.ProjectGrantStateUnspecified || writeModel.State == domain.ProjectGrantStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") - } - return writeModel, nil } diff --git a/internal/command/project_grant_model.go b/internal/command/project_grant_model.go index a8c1fe2850..15950d4f3d 100644 --- a/internal/command/project_grant_model.go +++ b/internal/command/project_grant_model.go @@ -16,13 +16,14 @@ type ProjectGrantWriteModel struct { State domain.ProjectGrantState } -func NewProjectGrantWriteModel(grantID, projectID, resourceOwner string) *ProjectGrantWriteModel { +func NewProjectGrantWriteModel(grantID, grantedOrgID, projectID, resourceOwner string) *ProjectGrantWriteModel { return &ProjectGrantWriteModel{ WriteModel: eventstore.WriteModel{ AggregateID: projectID, ResourceOwner: resourceOwner, }, - GrantID: grantID, + GrantID: grantID, + GrantedOrgID: grantedOrgID, } } @@ -30,27 +31,28 @@ func (wm *ProjectGrantWriteModel) AppendEvents(events ...eventstore.Event) { for _, event := range events { switch e := event.(type) { case *project.GrantAddedEvent: - if e.GrantID == wm.GrantID { + if (wm.GrantID != "" && e.GrantID == wm.GrantID) || + (wm.GrantedOrgID != "" && e.GrantedOrgID == wm.GrantedOrgID) { wm.WriteModel.AppendEvents(e) } case *project.GrantChangedEvent: - if e.GrantID == wm.GrantID { + if wm.GrantID != "" && e.GrantID == wm.GrantID { wm.WriteModel.AppendEvents(e) } case *project.GrantCascadeChangedEvent: - if e.GrantID == wm.GrantID { + if wm.GrantID != "" && e.GrantID == wm.GrantID { wm.WriteModel.AppendEvents(e) } case *project.GrantDeactivateEvent: - if e.GrantID == wm.GrantID { + if wm.GrantID != "" && e.GrantID == wm.GrantID { wm.WriteModel.AppendEvents(e) } case *project.GrantReactivatedEvent: - if e.GrantID == wm.GrantID { + if wm.GrantID != "" && e.GrantID == wm.GrantID { wm.WriteModel.AppendEvents(e) } case *project.GrantRemovedEvent: - if e.GrantID == wm.GrantID { + if wm.GrantID != "" && e.GrantID == wm.GrantID { wm.WriteModel.AppendEvents(e) } case *project.ProjectRemovedEvent: @@ -114,18 +116,20 @@ func (wm *ProjectGrantWriteModel) Query() *eventstore.SearchQueryBuilder { type ProjectGrantPreConditionReadModel struct { eventstore.WriteModel - ProjectID string - GrantedOrgID string - ProjectExists bool - GrantedOrgExists bool - ExistingRoleKeys []string + ProjectResourceOwner string + ProjectID string + GrantedOrgID string + ProjectExists bool + GrantedOrgExists bool + ExistingRoleKeys []string } func NewProjectGrantPreConditionReadModel(projectID, grantedOrgID, resourceOwner string) *ProjectGrantPreConditionReadModel { return &ProjectGrantPreConditionReadModel{ - WriteModel: eventstore.WriteModel{ResourceOwner: resourceOwner}, - ProjectID: projectID, - GrantedOrgID: grantedOrgID, + WriteModel: eventstore.WriteModel{}, + ProjectResourceOwner: resourceOwner, + ProjectID: projectID, + GrantedOrgID: grantedOrgID, } } @@ -133,26 +137,26 @@ func (wm *ProjectGrantPreConditionReadModel) Reduce() error { for _, event := range wm.Events { switch e := event.(type) { case *project.ProjectAddedEvent: - if wm.ResourceOwner == "" { - wm.ResourceOwner = e.Aggregate().ResourceOwner + if wm.ProjectResourceOwner == "" { + wm.ProjectResourceOwner = e.Aggregate().ResourceOwner } - if wm.ResourceOwner != e.Aggregate().ResourceOwner { + if wm.ProjectResourceOwner != e.Aggregate().ResourceOwner { continue } wm.ProjectExists = true case *project.ProjectRemovedEvent: - if wm.ResourceOwner != e.Aggregate().ResourceOwner { + if wm.ProjectResourceOwner != e.Aggregate().ResourceOwner { continue } - wm.ResourceOwner = "" + wm.ProjectResourceOwner = "" wm.ProjectExists = false case *project.RoleAddedEvent: - if e.Aggregate().ResourceOwner != wm.ResourceOwner { + if e.Aggregate().ResourceOwner != wm.ProjectResourceOwner { continue } wm.ExistingRoleKeys = append(wm.ExistingRoleKeys, e.Key) case *project.RoleRemovedEvent: - if e.Aggregate().ResourceOwner != wm.ResourceOwner { + if e.Aggregate().ResourceOwner != wm.ProjectResourceOwner { continue } for i, key := range wm.ExistingRoleKeys { @@ -175,12 +179,6 @@ func (wm *ProjectGrantPreConditionReadModel) Reduce() error { func (wm *ProjectGrantPreConditionReadModel) Query() *eventstore.SearchQueryBuilder { query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). AddQuery(). - AggregateTypes(org.AggregateType). - AggregateIDs(wm.GrantedOrgID). - EventTypes( - org.OrgAddedEventType, - org.OrgRemovedEventType). - Or(). AggregateTypes(project.AggregateType). AggregateIDs(wm.ProjectID). EventTypes( @@ -188,6 +186,12 @@ func (wm *ProjectGrantPreConditionReadModel) Query() *eventstore.SearchQueryBuil project.ProjectRemovedType, project.RoleAddedType, project.RoleRemovedType). + Or(). + AggregateTypes(org.AggregateType). + AggregateIDs(wm.GrantedOrgID). + EventTypes( + org.OrgAddedEventType, + org.OrgRemovedEventType). Builder() return query diff --git a/internal/command/project_grant_test.go b/internal/command/project_grant_test.go index f1befa0de2..7a3bb98e7d 100644 --- a/internal/command/project_grant_test.go +++ b/internal/command/project_grant_test.go @@ -720,6 +720,76 @@ func TestCommandSide_ChangeProjectGrant(t *testing.T) { }, }, }, + { + name: "projectgrant only added roles, grantedOrgID, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("grantedorg1").Aggregate, + "granted org", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "key1", + "key", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "key2", + "key2", + "", + ), + ), + ), + expectPush( + project.NewGrantChangedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + []string{"key1", "key2"}, + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectGrant: &ChangeProjectGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + ResourceOwner: "org1", + }, + GrantedOrgID: "grantedorg1", + RoleKeys: []string{"key1", "key2"}, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "projectgrant remove roles, usergrant not found, ok", fields: fields{ @@ -907,6 +977,7 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { ctx context.Context projectID string grantID string + grantedOrgID string resourceOwner string } type res struct { @@ -1076,6 +1147,48 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { }, }, }, + { + name: "projectgrant deactivate, grantedOrgID, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + ), + expectPush( + project.NewGrantDeactivateEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantedOrgID: "grantedorg1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1083,7 +1196,7 @@ func TestCommandSide_DeactivateProjectGrant(t *testing.T) { eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } - got, err := r.DeactivateProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.resourceOwner) + got, err := r.DeactivateProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.grantedOrgID, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -1106,6 +1219,7 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { ctx context.Context projectID string grantID string + grantedOrgID string resourceOwner string } type res struct { @@ -1275,6 +1389,52 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { }, }, }, + { + name: "projectgrant reactivate, grantedOrgID, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + eventFromEventPusher(project.NewGrantDeactivateEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + )), + ), + expectPush( + project.NewGrantReactivatedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantedOrgID: "grantedorg1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1282,7 +1442,7 @@ func TestCommandSide_ReactivateProjectGrant(t *testing.T) { eventstore: tt.fields.eventstore(t), checkPermission: tt.fields.checkPermission, } - got, err := r.ReactivateProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.resourceOwner) + got, err := r.ReactivateProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.grantedOrgID, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -1536,3 +1696,283 @@ func TestCommandSide_RemoveProjectGrant(t *testing.T) { }) } } + +func TestCommandSide_DeleteProjectGrant(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + projectID string + grantID string + grantedOrgID string + resourceOwner string + cascadeUserGrantIDs []string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "missing projectid, invalid error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + grantID: "projectgrant1", + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "missing grantid, invalid error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "project already removed, precondition failed error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + eventFromEventPusher( + project.NewProjectRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", + nil, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantID: "projectgrant1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "projectgrant not existing, precondition error", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantID: "projectgrant1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "projectgrant remove, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + ), + expectPush( + project.NewGrantRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantID: "projectgrant1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "projectgrant remove, grantedOrgID, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + ), + expectPush( + project.NewGrantRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantedOrgID: "grantedorg1", + resourceOwner: "org1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "projectgrant remove, cascading usergrant not found, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + ), + expectFilter(), + expectPush( + project.NewGrantRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantID: "projectgrant1", + resourceOwner: "org1", + cascadeUserGrantIDs: []string{"usergrant1"}, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "projectgrant remove with cascading usergrants, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + )), + ), + expectFilter( + eventFromEventPusher(usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", + []string{"key1"}))), + expectPush( + project.NewGrantRemovedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + ), + usergrant.NewUserGrantCascadeRemovedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + grantID: "projectgrant1", + resourceOwner: "org1", + cascadeUserGrantIDs: []string{"usergrant1"}, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.DeleteProjectGrant(tt.args.ctx, tt.args.projectID, tt.args.grantID, tt.args.grantedOrgID, tt.args.resourceOwner, tt.args.cascadeUserGrantIDs...) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} diff --git a/internal/command/project_old.go b/internal/command/project_old.go index e1b6f02721..99d7dd2e34 100644 --- a/internal/command/project_old.go +++ b/internal/command/project_old.go @@ -94,5 +94,5 @@ func (c *Commands) checkProjectGrantPreConditionOld(ctx context.Context, project if domain.HasInvalidRoles(preConditions.ExistingRoleKeys, roles) { return "", zerrors.ThrowPreconditionFailed(err, "COMMAND-6m9gd", "Errors.Project.Role.NotFound") } - return preConditions.ResourceOwner, nil + return preConditions.ProjectResourceOwner, nil } diff --git a/internal/domain/roles.go b/internal/domain/roles.go index b6bf2ffadd..c40eef6120 100644 --- a/internal/domain/roles.go +++ b/internal/domain/roles.go @@ -16,6 +16,7 @@ const ( RoleIAMOwner = "IAM_OWNER" RoleProjectOwner = "PROJECT_OWNER" RoleProjectOwnerGlobal = "PROJECT_OWNER_GLOBAL" + RoleProjectGrantOwner = "PROJECT_GRANT_OWNER" RoleSelfManagementGlobal = "SELF_MANAGEMENT_GLOBAL" ) diff --git a/internal/integration/client.go b/internal/integration/client.go index 3bf794f5f6..838a728faf 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -269,6 +269,14 @@ func (i *Instance) CreateUserTypeMachine(ctx context.Context) *user_v2.CreateUse return resp } +func (i *Instance) CreatePersonalAccessToken(ctx context.Context, userID string) *user_v2.AddPersonalAccessTokenResponse { + resp, err := i.Client.UserV2.AddPersonalAccessToken(ctx, &user_v2.AddPersonalAccessTokenRequest{ + UserId: userID, + }) + logging.OnError(err).Panic("create pat") + return resp +} + // TriggerUserByID makes sure the user projection gets triggered after creation. func (i *Instance) TriggerUserByID(ctx context.Context, users ...string) { var wg sync.WaitGroup @@ -903,6 +911,16 @@ func (i *Instance) CreateProjectMembership(t *testing.T, ctx context.Context, pr require.NoError(t, err) } +func (i *Instance) CreateProjectGrantMembership(t *testing.T, ctx context.Context, projectID, grantID, userID string) { + _, err := i.Client.Mgmt.AddProjectGrantMember(ctx, &mgmt.AddProjectGrantMemberRequest{ + ProjectId: projectID, + GrantId: grantID, + UserId: userID, + Roles: []string{domain.RoleProjectGrantOwner}, + }) + require.NoError(t, err) +} + func (i *Instance) CreateTarget(ctx context.Context, t *testing.T, name, endpoint string, ty domain.TargetType, interrupt bool) *action.CreateTargetResponse { if name == "" { name = gofakeit.Name() diff --git a/internal/query/project.go b/internal/query/project.go index ab58bd11a8..728731f7cb 100644 --- a/internal/query/project.go +++ b/internal/query/project.go @@ -126,6 +126,10 @@ var ( name: "project_grant_resource_owner", table: grantedProjectsAlias, } + grantedProjectColumnGrantID = Column{ + name: projection.ProjectGrantColumnGrantID, + table: grantedProjectsAlias, + } grantedProjectColumnGrantedOrganization = Column{ name: projection.ProjectGrantColumnGrantedOrgID, table: grantedProjectsAlias, @@ -157,20 +161,6 @@ func projectCheckPermission(ctx context.Context, resourceOwner string, projectID return permissionCheck(ctx, domain.PermissionProjectRead, resourceOwner, projectID) } -func projectPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *ProjectAndGrantedProjectSearchQueries) sq.SelectBuilder { - if !enabled { - return query - } - join, args := PermissionClause( - ctx, - grantedProjectColumnResourceOwner, - domain.PermissionProjectRead, - SingleOrgPermissionOption(queries.Queries), - OwnedRowsPermissionOption(GrantedProjectColumnID), - ) - return query.JoinClause(join, args...) -} - type Project struct { ID string CreationDate time.Time @@ -277,6 +267,20 @@ func (q *ProjectAndGrantedProjectSearchQueries) toQuery(query sq.SelectBuilder) return query } +func projectPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *ProjectAndGrantedProjectSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + grantedProjectColumnResourceOwner, + domain.PermissionProjectRead, + SingleOrgPermissionOption(queries.Queries), + WithProjectsPermissionOption(GrantedProjectColumnID), + ) + return query.JoinClause(join, args...) +} + func (q *Queries) SearchGrantedProjects(ctx context.Context, queries *ProjectAndGrantedProjectSearchQueries, permissionCheck domain.PermissionCheck) (*GrantedProjects, error) { permissionCheckV2 := PermissionV2(ctx, permissionCheck) projects, err := q.searchGrantedProjects(ctx, queries, permissionCheckV2) @@ -328,11 +332,11 @@ func NewGrantedProjectIDSearchQuery(ids []string) (SearchQuery, error) { } func NewGrantedProjectOrganizationIDSearchQuery(value string) (SearchQuery, error) { - project, err := NewTextQuery(grantedProjectColumnResourceOwner, value, TextEquals) + project, err := NewGrantedProjectResourceOwnerSearchQuery(value) if err != nil { return nil, err } - grant, err := NewTextQuery(grantedProjectColumnGrantedOrganization, value, TextEquals) + grant, err := NewGrantedProjectGrantedOrganizationIDSearchQuery(value) if err != nil { return nil, err } @@ -494,6 +498,9 @@ type GrantedProjects struct { func grantedProjectsCheckPermission(ctx context.Context, grantedProjects *GrantedProjects, permissionCheck domain.PermissionCheck) { grantedProjects.GrantedProjects = slices.DeleteFunc(grantedProjects.GrantedProjects, func(grantedProject *GrantedProject) bool { + if grantedProject.GrantedOrgID != "" { + return projectGrantCheckPermission(ctx, grantedProject.ResourceOwner, grantedProject.ProjectID, grantedProject.GrantID, grantedProject.GrantedOrgID, permissionCheck) != nil + } return projectCheckPermission(ctx, grantedProject.ResourceOwner, grantedProject.ProjectID, permissionCheck) != nil }, ) @@ -513,6 +520,7 @@ type GrantedProject struct { HasProjectCheck bool PrivateLabelingSetting domain.PrivateLabelingSetting + GrantID string GrantedOrgID string OrgName string ProjectGrantState domain.ProjectGrantState @@ -531,6 +539,7 @@ func prepareGrantedProjectsQuery() (sq.SelectBuilder, func(*sql.Rows) (*GrantedP grantedProjectColumnProjectRoleCheck.identifier(), grantedProjectColumnHasProjectCheck.identifier(), grantedProjectColumnPrivateLabelingSetting.identifier(), + grantedProjectColumnGrantID.identifier(), grantedProjectColumnGrantedOrganization.identifier(), grantedProjectColumnGrantedOrganizationName.identifier(), grantedProjectColumnGrantState.identifier(), @@ -541,6 +550,7 @@ func prepareGrantedProjectsQuery() (sq.SelectBuilder, func(*sql.Rows) (*GrantedP projects := make([]*GrantedProject, 0) var ( count uint64 + grantID = sql.NullString{} orgID = sql.NullString{} orgName = sql.NullString{} projectGrantState = sql.NullInt16{} @@ -559,6 +569,7 @@ func prepareGrantedProjectsQuery() (sq.SelectBuilder, func(*sql.Rows) (*GrantedP &grantedProject.ProjectRoleCheck, &grantedProject.HasProjectCheck, &grantedProject.PrivateLabelingSetting, + &grantID, &orgID, &orgName, &projectGrantState, @@ -567,6 +578,9 @@ func prepareGrantedProjectsQuery() (sq.SelectBuilder, func(*sql.Rows) (*GrantedP if err != nil { return nil, err } + if grantID.Valid { + grantedProject.GrantID = grantID.String + } if orgID.Valid { grantedProject.GrantedOrgID = orgID.String } @@ -614,6 +628,7 @@ func prepareProjects() string { ProjectColumnHasProjectCheck.identifier()+" AS "+grantedProjectColumnHasProjectCheck.name, ProjectColumnPrivateLabelingSetting.identifier()+" AS "+grantedProjectColumnPrivateLabelingSetting.name, "NULL::TEXT AS "+grantedProjectColumnGrantResourceOwner.name, + "NULL::TEXT AS "+grantedProjectColumnGrantID.name, "NULL::TEXT AS "+grantedProjectColumnGrantedOrganization.name, "NULL::TEXT AS "+grantedProjectColumnGrantedOrganizationName.name, "NULL::SMALLINT AS "+grantedProjectColumnGrantState.name, @@ -641,6 +656,7 @@ func prepareGrantedProjects() string { ProjectColumnHasProjectCheck.identifier()+" AS "+grantedProjectColumnHasProjectCheck.name, ProjectColumnPrivateLabelingSetting.identifier()+" AS "+grantedProjectColumnPrivateLabelingSetting.name, ProjectGrantColumnResourceOwner.identifier()+" AS "+grantedProjectColumnGrantResourceOwner.name, + ProjectGrantColumnGrantID.identifier()+" AS "+grantedProjectColumnGrantID.name, ProjectGrantColumnGrantedOrgID.identifier()+" AS "+grantedProjectColumnGrantedOrganization.name, ProjectGrantColumnGrantedOrgName.identifier()+" AS "+grantedProjectColumnGrantedOrganizationName.name, ProjectGrantColumnState.identifier()+" AS "+grantedProjectColumnGrantState.name, diff --git a/internal/query/project_grant.go b/internal/query/project_grant.go index a0dbd7c121..3093d26f30 100644 --- a/internal/query/project_grant.go +++ b/internal/query/project_grant.go @@ -108,15 +108,23 @@ type ProjectGrantSearchQueries struct { func projectGrantsCheckPermission(ctx context.Context, projectGrants *ProjectGrants, permissionCheck domain.PermissionCheck) { projectGrants.ProjectGrants = slices.DeleteFunc(projectGrants.ProjectGrants, func(projectGrant *ProjectGrant) bool { - return projectGrantCheckPermission(ctx, projectGrant.ResourceOwner, projectGrant.GrantID, permissionCheck) != nil + return projectGrantCheckPermission(ctx, projectGrant.ResourceOwner, projectGrant.ProjectID, projectGrant.GrantID, projectGrant.GrantedOrgID, permissionCheck) != nil }, ) } -func projectGrantCheckPermission(ctx context.Context, resourceOwner string, grantID string, permissionCheck domain.PermissionCheck) error { - return permissionCheck(ctx, domain.PermissionProjectGrantRead, resourceOwner, grantID) +func projectGrantCheckPermission(ctx context.Context, resourceOwner, projectID, grantID, grantedOrgID string, permissionCheck domain.PermissionCheck) error { + if err := permissionCheck(ctx, domain.PermissionProjectGrantRead, resourceOwner, grantID); err != nil { + if err := permissionCheck(ctx, domain.PermissionProjectGrantRead, grantedOrgID, grantID); err != nil { + if err := permissionCheck(ctx, domain.PermissionProjectGrantRead, resourceOwner, projectID); err != nil { + return err + } + } + } + return nil } +// TODO: add permission check on project grant level func projectGrantPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *ProjectGrantSearchQueries) sq.SelectBuilder { if !enabled { return query @@ -126,7 +134,6 @@ func projectGrantPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, ProjectGrantColumnResourceOwner, domain.PermissionProjectGrantRead, SingleOrgPermissionOption(queries.Queries), - OwnedRowsPermissionOption(ProjectGrantColumnGrantID), ) return query.JoinClause(join, args...) } diff --git a/internal/query/project_role.go b/internal/query/project_role.go index 15ae806cd4..e70fcf277e 100644 --- a/internal/query/project_role.go +++ b/internal/query/project_role.go @@ -103,7 +103,7 @@ func projectRolePermissionCheckV2(ctx context.Context, query sq.SelectBuilder, e ProjectRoleColumnResourceOwner, domain.PermissionProjectRoleRead, SingleOrgPermissionOption(queries.Queries), - OwnedRowsPermissionOption(ProjectRoleColumnKey), + WithProjectsPermissionOption(ProjectRoleColumnProjectID), ) return query.JoinClause(join, args...) } From 7df4f76f3c6d41320bc4639446afa08d9c631032 Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:05:35 +0200 Subject: [PATCH 089/123] feat(api): reworking AddOrganization() API call to return all admins (#9900) --- internal/api/grpc/admin/org.go | 6 +- internal/api/grpc/org/v2/org.go | 15 ++-- internal/api/grpc/org/v2/org_test.go | 4 +- internal/api/grpc/org/v2beta/helper.go | 33 +++++-- .../org/v2beta/integration_test/org_test.go | 90 +++++++++++++------ internal/api/grpc/org/v2beta/org_test.go | 16 ++-- internal/command/org.go | 25 +++++- internal/command/org_test.go | 16 ++-- proto/zitadel/org/v2beta/org_service.proto | 18 +++- 9 files changed, 160 insertions(+), 63 deletions(-) diff --git a/internal/api/grpc/admin/org.go b/internal/api/grpc/admin/org.go index 293e7c74d7..ef97e47bb0 100644 --- a/internal/api/grpc/admin/org.go +++ b/internal/api/grpc/admin/org.go @@ -78,7 +78,7 @@ func (s *Server) SetUpOrg(ctx context.Context, req *admin_pb.SetUpOrgRequest) (* if err != nil { return nil, err } - human := setUpOrgHumanToCommand(req.User.(*admin_pb.SetUpOrgRequest_Human_).Human) //TODO: handle machine + human := setUpOrgHumanToCommand(req.User.(*admin_pb.SetUpOrgRequest_Human_).Human) // TODO: handle machine createdOrg, err := s.command.SetUpOrg(ctx, &command.OrgSetup{ Name: req.Org.Name, CustomDomain: req.Org.Domain, @@ -93,8 +93,8 @@ func (s *Server) SetUpOrg(ctx context.Context, req *admin_pb.SetUpOrgRequest) (* return nil, err } var userID string - if len(createdOrg.CreatedAdmins) == 1 { - userID = createdOrg.CreatedAdmins[0].ID + if len(createdOrg.OrgAdmins) == 1 { + userID = createdOrg.OrgAdmins[0].GetID() } return &admin_pb.SetUpOrgResponse{ Details: object.DomainToAddDetailsPb(createdOrg.ObjectDetails), diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go index bbc3caca85..b876826365 100644 --- a/internal/api/grpc/org/v2/org.go +++ b/internal/api/grpc/org/v2/org.go @@ -69,12 +69,15 @@ func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admi } func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) { - admins := make([]*org.AddOrganizationResponse_CreatedAdmin, len(createdOrg.CreatedAdmins)) - for i, admin := range createdOrg.CreatedAdmins { - admins[i] = &org.AddOrganizationResponse_CreatedAdmin{ - UserId: admin.ID, - EmailCode: admin.EmailCode, - PhoneCode: admin.PhoneCode, + admins := make([]*org.AddOrganizationResponse_CreatedAdmin, 0, len(createdOrg.OrgAdmins)) + for _, admin := range createdOrg.OrgAdmins { + admin, ok := admin.(*command.CreatedOrgAdmin) + if ok { + admins = append(admins, &org.AddOrganizationResponse_CreatedAdmin{ + UserId: admin.GetID(), + EmailCode: admin.EmailCode, + PhoneCode: admin.PhoneCode, + }) } } return &org.AddOrganizationResponse{ diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go index 7ae252a209..37a3dca41a 100644 --- a/internal/api/grpc/org/v2/org_test.go +++ b/internal/api/grpc/org/v2/org_test.go @@ -150,8 +150,8 @@ func Test_createdOrganizationToPb(t *testing.T) { EventDate: now, ResourceOwner: "orgID", }, - CreatedAdmins: []*command.CreatedOrgAdmin{ - { + OrgAdmins: []command.OrgAdmin{ + &command.CreatedOrgAdmin{ ID: "id", EmailCode: gu.Ptr("emailCode"), PhoneCode: gu.Ptr("phoneCode"), diff --git a/internal/api/grpc/org/v2beta/helper.go b/internal/api/grpc/org/v2beta/helper.go index 39bad0dae2..6f47819bb4 100644 --- a/internal/api/grpc/org/v2beta/helper.go +++ b/internal/api/grpc/org/v2beta/helper.go @@ -72,18 +72,33 @@ func OrgStateToPb(state domain.OrgState) v2beta_org.OrgState { } func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrganizationResponse, err error) { - admins := make([]*org.CreatedAdmin, len(createdOrg.CreatedAdmins)) - for i, admin := range createdOrg.CreatedAdmins { - admins[i] = &org.CreatedAdmin{ - UserId: admin.ID, - EmailCode: admin.EmailCode, - PhoneCode: admin.PhoneCode, + admins := make([]*org.OrganizationAdmin, len(createdOrg.OrgAdmins)) + for i, admin := range createdOrg.OrgAdmins { + switch admin := admin.(type) { + case *command.CreatedOrgAdmin: + admins[i] = &org.OrganizationAdmin{ + OrganizationAdmin: &org.OrganizationAdmin_CreatedAdmin{ + CreatedAdmin: &org.CreatedAdmin{ + UserId: admin.ID, + EmailCode: admin.EmailCode, + PhoneCode: admin.PhoneCode, + }, + }, + } + case *command.AssignedOrgAdmin: + admins[i] = &org.OrganizationAdmin{ + OrganizationAdmin: &org.OrganizationAdmin_AssignedAdmin{ + AssignedAdmin: &org.AssignedAdmin{ + UserId: admin.ID, + }, + }, + } } } return &org.CreateOrganizationResponse{ - CreationDate: timestamppb.New(createdOrg.ObjectDetails.EventDate), - Id: createdOrg.ObjectDetails.ResourceOwner, - CreatedAdmins: admins, + CreationDate: timestamppb.New(createdOrg.ObjectDetails.EventDate), + Id: createdOrg.ObjectDetails.ResourceOwner, + OrganizationAdmins: admins, }, nil } diff --git a/internal/api/grpc/org/v2beta/integration_test/org_test.go b/internal/api/grpc/org/v2beta/integration_test/org_test.go index 4e0ec26121..0d3b920afe 100644 --- a/internal/api/grpc/org/v2beta/integration_test/org_test.go +++ b/internal/api/grpc/org/v2beta/integration_test/org_test.go @@ -18,7 +18,6 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/admin" v2beta_object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" - org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" "github.com/zitadel/zitadel/pkg/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" @@ -85,6 +84,29 @@ func TestServer_CreateOrganization(t *testing.T) { }, wantErr: true, }, + { + name: "existing user as admin", + ctx: CTX, + req: &v2beta_org.CreateOrganizationRequest{ + Name: gofakeit.AppName(), + Admins: []*v2beta_org.CreateOrganizationRequest_Admin{ + { + UserType: &v2beta_org.CreateOrganizationRequest_Admin_UserId{UserId: User.GetUserId()}, + }, + }, + }, + want: &v2beta_org.CreateOrganizationResponse{ + OrganizationAdmins: []*v2beta_org.OrganizationAdmin{ + { + OrganizationAdmin: &v2beta_org.OrganizationAdmin_AssignedAdmin{ + AssignedAdmin: &v2beta_org.AssignedAdmin{ + UserId: User.GetUserId(), + }, + }, + }, + }, + }, + }, { name: "admin with init", ctx: CTX, @@ -111,11 +133,15 @@ func TestServer_CreateOrganization(t *testing.T) { }, want: &v2beta_org.CreateOrganizationResponse{ Id: integration.NotEmpty, - CreatedAdmins: []*v2beta_org.CreatedAdmin{ + OrganizationAdmins: []*v2beta_org.OrganizationAdmin{ { - UserId: integration.NotEmpty, - EmailCode: gu.Ptr(integration.NotEmpty), - PhoneCode: nil, + OrganizationAdmin: &v2beta_org.OrganizationAdmin_CreatedAdmin{ + CreatedAdmin: &v2beta_org.CreatedAdmin{ + UserId: integration.NotEmpty, + EmailCode: gu.Ptr(integration.NotEmpty), + PhoneCode: nil, + }, + }, }, }, }, @@ -155,10 +181,21 @@ func TestServer_CreateOrganization(t *testing.T) { }, }, want: &v2beta_org.CreateOrganizationResponse{ - CreatedAdmins: []*v2beta_org.CreatedAdmin{ - // a single admin is expected, because the first provided already exists + // OrganizationId: integration.NotEmpty, + OrganizationAdmins: []*v2beta_org.OrganizationAdmin{ { - UserId: integration.NotEmpty, + OrganizationAdmin: &v2beta_org.OrganizationAdmin_AssignedAdmin{ + AssignedAdmin: &v2beta_org.AssignedAdmin{ + UserId: User.GetUserId(), + }, + }, + }, + { + OrganizationAdmin: &v2beta_org.OrganizationAdmin_CreatedAdmin{ + CreatedAdmin: &v2beta_org.CreatedAdmin{ + UserId: integration.NotEmpty, + }, + }, }, }, }, @@ -192,13 +229,16 @@ func TestServer_CreateOrganization(t *testing.T) { now := time.Now() assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) - // organization id must be the same as the resourceOwner - // check the admins - require.Len(t, got.GetCreatedAdmins(), len(tt.want.GetCreatedAdmins())) - for i, admin := range tt.want.GetCreatedAdmins() { - gotAdmin := got.GetCreatedAdmins()[i] - assertCreatedAdmin(t, admin, gotAdmin) + require.Equal(t, len(tt.want.GetOrganizationAdmins()), len(got.GetOrganizationAdmins())) + for i, admin := range tt.want.GetOrganizationAdmins() { + gotAdmin := got.GetOrganizationAdmins()[i].OrganizationAdmin + switch admin := admin.OrganizationAdmin.(type) { + case *v2beta_org.OrganizationAdmin_CreatedAdmin: + assertCreatedAdmin(t, admin.CreatedAdmin, gotAdmin.(*v2beta_org.OrganizationAdmin_CreatedAdmin).CreatedAdmin) + case *v2beta_org.OrganizationAdmin_AssignedAdmin: + assert.Equal(t, admin.AssignedAdmin.GetUserId(), gotAdmin.(*v2beta_org.OrganizationAdmin_AssignedAdmin).AssignedAdmin.GetUserId()) + } } }) } @@ -472,8 +512,8 @@ func TestServer_ListOrganizations(t *testing.T) { ctx: listOrgIAmOwnerCtx, query: []*v2beta_org.OrganizationSearchFilter{ { - Filter: &org.OrganizationSearchFilter_DomainFilter{ - DomainFilter: &org.OrgDomainFilter{ + Filter: &v2beta_org.OrganizationSearchFilter_DomainFilter{ + DomainFilter: &v2beta_org.OrgDomainFilter{ Domain: func() string { listOrgRes, err := listOrgClient.ListOrganizations(listOrgIAmOwnerCtx, &v2beta_org.ListOrganizationsRequest{ Filter: []*v2beta_org.OrganizationSearchFilter{ @@ -507,8 +547,8 @@ func TestServer_ListOrganizations(t *testing.T) { ctx: listOrgIAmOwnerCtx, query: []*v2beta_org.OrganizationSearchFilter{ { - Filter: &org.OrganizationSearchFilter_DomainFilter{ - DomainFilter: &org.OrgDomainFilter{ + Filter: &v2beta_org.OrganizationSearchFilter_DomainFilter{ + DomainFilter: &v2beta_org.OrgDomainFilter{ Domain: func() string { domain := strings.ToLower(strings.ReplaceAll(orgsName[1][1:len(orgsName[1])-2], " ", "-")) return domain @@ -530,8 +570,8 @@ func TestServer_ListOrganizations(t *testing.T) { ctx: listOrgIAmOwnerCtx, query: []*v2beta_org.OrganizationSearchFilter{ { - Filter: &org.OrganizationSearchFilter_DomainFilter{ - DomainFilter: &org.OrgDomainFilter{ + Filter: &v2beta_org.OrganizationSearchFilter_DomainFilter{ + DomainFilter: &v2beta_org.OrgDomainFilter{ Domain: func() string { domain := strings.ToUpper(strings.ReplaceAll(orgsName[1][1:len(orgsName[1])-2], " ", "-")) return domain @@ -1374,7 +1414,7 @@ func TestServer_ValidateOrganizationDomain(t *testing.T) { req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ OrganizationId: orgId, Domain: domain, - Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, + Type: v2beta_org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, }, }, { @@ -1383,7 +1423,7 @@ func TestServer_ValidateOrganizationDomain(t *testing.T) { req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ OrganizationId: "non existent org id", Domain: domain, - Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, + Type: v2beta_org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, }, // BUG: this should be 'organization does not exist' err: errors.New("Domain doesn't exist on organization"), @@ -1394,7 +1434,7 @@ func TestServer_ValidateOrganizationDomain(t *testing.T) { req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ OrganizationId: orgId, Domain: domain, - Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS, + Type: v2beta_org.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS, }, }, { @@ -1403,7 +1443,7 @@ func TestServer_ValidateOrganizationDomain(t *testing.T) { req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ OrganizationId: "non existent org id", Domain: domain, - Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS, + Type: v2beta_org.DomainValidationType_DOMAIN_VALIDATION_TYPE_DNS, }, // BUG: this should be 'organization does not exist' err: errors.New("Domain doesn't exist on organization"), @@ -1414,7 +1454,7 @@ func TestServer_ValidateOrganizationDomain(t *testing.T) { req: &v2beta_org.GenerateOrganizationDomainValidationRequest{ OrganizationId: orgId, Domain: "non existent domain", - Type: org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, + Type: v2beta_org.DomainValidationType_DOMAIN_VALIDATION_TYPE_HTTP, }, err: errors.New("Domain doesn't exist on organization"), }, diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go index 2047f665a1..346d6b88c1 100644 --- a/internal/api/grpc/org/v2beta/org_test.go +++ b/internal/api/grpc/org/v2beta/org_test.go @@ -150,8 +150,8 @@ func Test_createdOrganizationToPb(t *testing.T) { EventDate: now, ResourceOwner: "orgID", }, - CreatedAdmins: []*command.CreatedOrgAdmin{ - { + OrgAdmins: []command.OrgAdmin{ + &command.CreatedOrgAdmin{ ID: "id", EmailCode: gu.Ptr("emailCode"), PhoneCode: gu.Ptr("phoneCode"), @@ -162,11 +162,15 @@ func Test_createdOrganizationToPb(t *testing.T) { want: &org.CreateOrganizationResponse{ CreationDate: timestamppb.New(now), Id: "orgID", - CreatedAdmins: []*org.CreatedAdmin{ + OrganizationAdmins: []*org.OrganizationAdmin{ { - UserId: "id", - EmailCode: gu.Ptr("emailCode"), - PhoneCode: gu.Ptr("phoneCode"), + OrganizationAdmin: &org.OrganizationAdmin_CreatedAdmin{ + CreatedAdmin: &org.CreatedAdmin{ + UserId: "id", + EmailCode: gu.Ptr("emailCode"), + PhoneCode: gu.Ptr("phoneCode"), + }, + }, }, }, }, diff --git a/internal/command/org.go b/internal/command/org.go index b6650ef7f2..ddf99797e3 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -54,7 +54,11 @@ type orgSetupCommands struct { type CreatedOrg struct { ObjectDetails *domain.ObjectDetails - CreatedAdmins []*CreatedOrgAdmin + OrgAdmins []OrgAdmin +} + +type OrgAdmin interface { + GetID() string } type CreatedOrgAdmin struct { @@ -65,6 +69,18 @@ type CreatedOrgAdmin struct { MachineKey *MachineKey } +func (a *CreatedOrgAdmin) GetID() string { + return a.ID +} + +type AssignedOrgAdmin struct { + ID string +} + +func (a *AssignedOrgAdmin) GetID() string { + return a.ID +} + func (o *OrgSetup) Validate() (err error) { if o.OrgID != "" && strings.TrimSpace(o.OrgID) == "" { return zerrors.ThrowInvalidArgument(nil, "ORG-4ABd3", "Errors.Invalid.Argument") @@ -188,14 +204,15 @@ func (c *orgSetupCommands) push(ctx context.Context) (_ *CreatedOrg, err error) EventDate: events[len(events)-1].CreatedAt(), ResourceOwner: c.aggregate.ID, }, - CreatedAdmins: c.createdAdmins(), + OrgAdmins: c.createdAdmins(), }, nil } -func (c *orgSetupCommands) createdAdmins() []*CreatedOrgAdmin { - users := make([]*CreatedOrgAdmin, 0, len(c.admins)) +func (c *orgSetupCommands) createdAdmins() []OrgAdmin { + users := make([]OrgAdmin, 0, len(c.admins)) for _, admin := range c.admins { if admin.ID != "" && admin.Human == nil { + users = append(users, &AssignedOrgAdmin{ID: admin.ID}) continue } if admin.Human != nil { diff --git a/internal/command/org_test.go b/internal/command/org_test.go index 4b6fd7afe5..4239be760a 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -1531,8 +1531,8 @@ func TestCommandSide_SetUpOrg(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ ResourceOwner: "orgID", }, - CreatedAdmins: []*CreatedOrgAdmin{ - { + OrgAdmins: []OrgAdmin{ + &CreatedOrgAdmin{ ID: "userID", }, }, @@ -1574,7 +1574,7 @@ func TestCommandSide_SetUpOrg(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ ResourceOwner: "custom-org-ID", }, - CreatedAdmins: []*CreatedOrgAdmin{}, + OrgAdmins: []OrgAdmin{}, }, }, }, @@ -1641,7 +1641,11 @@ func TestCommandSide_SetUpOrg(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ ResourceOwner: "orgID", }, - CreatedAdmins: []*CreatedOrgAdmin{}, + OrgAdmins: []OrgAdmin{ + &AssignedOrgAdmin{ + ID: "userID", + }, + }, }, }, }, @@ -1751,8 +1755,8 @@ func TestCommandSide_SetUpOrg(t *testing.T) { ObjectDetails: &domain.ObjectDetails{ ResourceOwner: "orgID", }, - CreatedAdmins: []*CreatedOrgAdmin{ - { + OrgAdmins: []OrgAdmin{ + &CreatedOrgAdmin{ ID: "userID", PAT: &PersonalAccessToken{ ObjectRoot: models.ObjectRoot{ diff --git a/proto/zitadel/org/v2beta/org_service.proto b/proto/zitadel/org/v2beta/org_service.proto index 28c823a89b..387b2cb825 100644 --- a/proto/zitadel/org/v2beta/org_service.proto +++ b/proto/zitadel/org/v2beta/org_service.proto @@ -558,6 +558,18 @@ message CreatedAdmin { optional string phone_code = 3; } +message AssignedAdmin { + string user_id = 1; +} + +message OrganizationAdmin { + // The admins created/assigned for the Organization. + oneof OrganizationAdmin { + CreatedAdmin created_admin = 1; + AssignedAdmin assigned_admin = 2; + } +} + message CreateOrganizationResponse{ // The timestamp of the organization was created. google.protobuf.Timestamp creation_date = 1 [ @@ -577,8 +589,8 @@ message CreateOrganizationResponse{ } ]; - // The admins created for the Organization - repeated CreatedAdmin created_admins = 3; + // The admins created/assigned for the Organization + repeated OrganizationAdmin organization_admins = 3; } message UpdateOrganizationRequest { @@ -939,3 +951,5 @@ message DeleteOrganizationMetadataResponse{ } ]; } + + From 63c92104baec2d326a3f296cd0572a98b56be199 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Thu, 5 Jun 2025 12:13:26 +0200 Subject: [PATCH 090/123] chore: service ping api design (#9984) # Which Problems Are Solved Add the possibility to report information and analytical data from (self-hosted) ZITADEL systems to a central endpoint. To be able to do so an API has to be designed to receive the different reports and information. # How the Problems Are Solved - Telemetry service definition added, which currently has two endpoints: - ReportBaseInformation: To gather the zitadel version and instance information such as id and creation date - ReportResourceCounts: Dynamically report (based on #9979) different resources (orgs, users per org, ...) - To be able to paginate and send multiple pages to the endpoint a `report_id` is returned on the first page / request from the server, which needs to be passed by the client on the following pages. - Base error handling is described in the proto and is based on gRPC standards and best practices. # Additional Changes none # Additional Context Public documentation of the behaviour / error handling and what data is collected, resp. how to configure will be provided in https://github.com/zitadel/zitadel/issues/9869. Closes https://github.com/zitadel/zitadel/issues/9872 --- .../zitadel/analytics/v2beta/telemetry.proto | 48 +++++++++++ .../analytics/v2beta/telemetry_service.proto | 79 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 proto/zitadel/analytics/v2beta/telemetry.proto create mode 100644 proto/zitadel/analytics/v2beta/telemetry_service.proto diff --git a/proto/zitadel/analytics/v2beta/telemetry.proto b/proto/zitadel/analytics/v2beta/telemetry.proto new file mode 100644 index 0000000000..f0e1537f9a --- /dev/null +++ b/proto/zitadel/analytics/v2beta/telemetry.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package zitadel.analytics.v2beta; + +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta;analytics"; + + +message InstanceInformation { + // The unique identifier of the instance. + string id = 1; + // The custom domains (incl. generated ones) of the instance. + repeated string domains = 2; + // The creation date of the instance. + google.protobuf.Timestamp created_at = 3; +} + +message ResourceCount { + // The ID of the instance for which the resource counts are reported. + string instance_id = 3; + // The parent type of the resource counts (e.g. organization or instance). + // For example, reporting the amount of users per organization would use + // `COUNT_PARENT_TYPE_ORGANIZATION` as parent type and the organization ID as parent ID. + CountParentType parent_type = 4; + // The parent ID of the resource counts (e.g. organization or instance ID). + // For example, reporting the amount of users per organization would use + // `COUNT_PARENT_TYPE_ORGANIZATION` as parent type and the organization ID as parent ID. + string parent_id = 5; + // The resource counts to report, e.g. amount of `users`, `organizations`, etc. + string resource_name = 6; + // The name of the table in the database, which was used to calculate the counts. + // This can be used to deduplicate counts in case of multiple reports. + // For example, if the counts were calculated from the `users14` table, + // the table name would be `users14`, where there could also be a `users15` table + // reported at the same time as the system is rolling out a new version. + string table_name = 7; + // The timestamp when the count was last updated. + google.protobuf.Timestamp updated_at = 8; + // The actual amount of the resource. + uint32 amount = 9; +} + +enum CountParentType { + COUNT_PARENT_TYPE_UNSPECIFIED = 0; + COUNT_PARENT_TYPE_INSTANCE = 1; + COUNT_PARENT_TYPE_ORGANIZATION = 2; +} diff --git a/proto/zitadel/analytics/v2beta/telemetry_service.proto b/proto/zitadel/analytics/v2beta/telemetry_service.proto new file mode 100644 index 0000000000..e71536a811 --- /dev/null +++ b/proto/zitadel/analytics/v2beta/telemetry_service.proto @@ -0,0 +1,79 @@ +syntax = "proto3"; + +package zitadel.analytics.v2beta; + +import "google/protobuf/timestamp.proto"; +import "zitadel/analytics/v2beta/telemetry.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/analytics/v2beta;analytics"; + +// The TelemetryService is used to report telemetry such as usage statistics of the ZITADEL instance(s). +// back to a central storage. +// It is used to collect anonymized data about the usage of ZITADEL features, capabilities, and configurations. +// ZITADEL acts as a client of the TelemetryService. +// +// Reports are sent periodically based on the system's runtime configuration. +// The content of the reports, respectively the data collected, can be configured in the system's runtime configuration. +// +// All endpoints follow the same error and retry handling: +// In case of a failure to report the usage, ZITADEL will retry to report the usage +// based on the configured retry policy and error type: +// - Client side errors will not be retried, as they indicate a misconfiguration or an invalid request: +// - `INVALID_ARGUMENT`: The request was malformed. +// - `NOT_FOUND`: The TelemetryService's endpoint is likely misconfigured. +// - Connection / transfer errors will be retried based on the retry policy configured in the system's runtime configuration: +// - `DEADLINE_EXCEEDED`: The request took too long to complete, it will be retried. +// - `RESOURCE_EXHAUSTED`: The request was rejected due to resource exhaustion, it will be retried after a backoff period. +// - `UNAVAILABLE`: The TelemetryService is currently unavailable, it will be retried after a backoff period. +// Server side errors will also be retried based on the information provided by the server: +// - `FAILED_PRECONDITION`: The request failed due to a precondition, e.g. the report ID does not exists, +// does not correspond to the same system ID or previous reporting is too old, do not retry. +// - `INTERNAL`: An internal error occurred. Check details and logs. +service TelemetryService { + + // ReportBaseInformation is used to report the base information of the ZITADEL system, + // including the version, instances, their creation date and domains. + // The response contains a report ID to link it to the resource counts or other reports. + // The report ID is only valid for the same system ID. + rpc ReportBaseInformation (ReportBaseInformationRequest) returns (ReportBaseInformationResponse) {} + + // ReportResourceCounts is used to report the resource counts such as amount of organizations + // or users per organization and much more. + // Since the resource counts can be reported in multiple batches, + // the response contains a report ID to continue reporting. + // The report ID is only valid for the same system ID. + rpc ReportResourceCounts (ReportResourceCountsRequest) returns (ReportResourceCountsResponse) {} +} + +message ReportBaseInformationRequest { + // The system ID is a unique identifier for the ZITADEL system. + string system_id = 1; + // The current version of the ZITADEL system. + string version = 2; + // A list of instances in the ZITADEL system and their information. + repeated InstanceInformation instances = 3; +} + +message ReportBaseInformationResponse { + // The report ID is a unique identifier for the report. + // It is used to identify the report to be able to link it to the resource counts or other reports. + // Note that the report ID is only valid for the same system ID. + string report_id = 1; +} + +message ReportResourceCountsRequest { + // The system ID is a unique identifier for the ZITADEL system. + string system_id = 1; + // The previously returned report ID from the server to continue reporting. + // Note that the report ID is only valid for the same system ID. + optional string report_id = 2; + // A list of resource counts to report. + repeated ResourceCount resource_counts = 3; +} + +message ReportResourceCountsResponse { + // The report ID is a unique identifier for the report. + // It is used to identify the report in case of additional data / pagination. + // Note that the report ID is only valid for the same system ID. + string report_id = 1; +} \ No newline at end of file From 6c309d65c6d6367959d012a9b97a46a26cc8be6a Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Thu, 5 Jun 2025 13:42:59 +0200 Subject: [PATCH 091/123] fix(fields): project by id and resource owner (#10034) # Which Problems Are Solved If the `IMPROVED_PERFORMANCE_PROJECT` feature flag was enabled it was not possible to remove organizations anymore because the project was searched in the `eventstore.fields` table without resource owner. # How the Problems Are Solved Search now includes resource owner. --- internal/command/project.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/command/project.go b/internal/command/project.go index bf72306417..40aa79f186 100644 --- a/internal/command/project.go +++ b/internal/command/project.go @@ -138,14 +138,14 @@ func projectWriteModel(ctx context.Context, filter preparation.FilterToQueryRedu return project, nil } -func (c *Commands) projectAggregateByID(ctx context.Context, projectID string) (*eventstore.Aggregate, domain.ProjectState, error) { - result, err := c.projectState(ctx, projectID) +func (c *Commands) projectAggregateByID(ctx context.Context, projectID, resourceOwner string) (*eventstore.Aggregate, domain.ProjectState, error) { + result, err := c.projectState(ctx, projectID, resourceOwner) if err != nil { return nil, domain.ProjectStateUnspecified, zerrors.ThrowNotFound(err, "COMMA-NDQoF", "Errors.Project.NotFound") } if len(result) == 0 { _ = projection.ProjectGrantFields.Trigger(ctx) - result, err = c.projectState(ctx, projectID) + result, err = c.projectState(ctx, projectID, resourceOwner) if err != nil || len(result) == 0 { return nil, domain.ProjectStateUnspecified, zerrors.ThrowNotFound(err, "COMMA-U1nza", "Errors.Project.NotFound") } @@ -159,7 +159,7 @@ func (c *Commands) projectAggregateByID(ctx context.Context, projectID string) ( return &result[0].Aggregate, state, nil } -func (c *Commands) projectState(ctx context.Context, projectID string) ([]*eventstore.SearchResult, error) { +func (c *Commands) projectState(ctx context.Context, projectID, resourceOwner string) ([]*eventstore.SearchResult, error) { return c.eventstore.Search( ctx, map[eventstore.FieldType]any{ @@ -167,6 +167,7 @@ func (c *Commands) projectState(ctx context.Context, projectID string) ([]*event eventstore.FieldTypeObjectID: projectID, eventstore.FieldTypeObjectRevision: project.ProjectObjectRevision, eventstore.FieldTypeFieldName: project.ProjectStateSearchField, + eventstore.FieldTypeResourceOwner: resourceOwner, }, ) } @@ -179,7 +180,7 @@ func (c *Commands) checkProjectExists(ctx context.Context, projectID, resourceOw return c.checkProjectExistsOld(ctx, projectID, resourceOwner) } - agg, state, err := c.projectAggregateByID(ctx, projectID) + agg, state, err := c.projectAggregateByID(ctx, projectID, resourceOwner) if err != nil || !state.Valid() { return "", zerrors.ThrowPreconditionFailed(err, "COMMA-VCnwD", "Errors.Project.NotFound") } @@ -249,7 +250,7 @@ func (c *Commands) DeactivateProject(ctx context.Context, projectID string, reso return c.deactivateProjectOld(ctx, projectID, resourceOwner) } - projectAgg, state, err := c.projectAggregateByID(ctx, projectID) + projectAgg, state, err := c.projectAggregateByID(ctx, projectID, resourceOwner) if err != nil { return nil, err } @@ -285,7 +286,7 @@ func (c *Commands) ReactivateProject(ctx context.Context, projectID string, reso return c.reactivateProjectOld(ctx, projectID, resourceOwner) } - projectAgg, state, err := c.projectAggregateByID(ctx, projectID) + projectAgg, state, err := c.projectAggregateByID(ctx, projectID, resourceOwner) if err != nil { return nil, err } From 647b3b57cffe4c34d3ae9d0d66e635a967f7eea9 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Thu, 5 Jun 2025 15:50:21 +0200 Subject: [PATCH 092/123] fix: correct id filter for project service (#10035) # Which Problems Are Solved IDs filter definition was changed in another PR and not changed in the Project service. # How the Problems Are Solved Correctly use the IDs filter. # Additional Changes Add timeout to the integration tests. # Additional Context None --- .../grpc/project/v2beta/integration/query_test.go | 12 ++++-------- internal/integration/client.go | 4 +++- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/internal/api/grpc/project/v2beta/integration/query_test.go b/internal/api/grpc/project/v2beta/integration/query_test.go index fc153f0130..b648e8c1d7 100644 --- a/internal/api/grpc/project/v2beta/integration/query_test.go +++ b/internal/api/grpc/project/v2beta/integration/query_test.go @@ -389,9 +389,7 @@ func TestServer_ListProjects(t *testing.T) { resp2 := createProject(iamOwnerCtx, instance, t, orgID, true, false) resp3 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{resp1.GetId(), resp2.GetId(), resp3.GetId(), projectResp.GetId()}, - }, + InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId(), projectResp.GetId()}}, } response.Projects[0] = grantedProjectResp response.Projects[1] = projectResp @@ -516,7 +514,7 @@ func TestServer_ListProjects(t *testing.T) { projectResp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), projectName, true, true) projectGrantResp := instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgID) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ProjectIds: []string{projectResp.GetId()}}, + InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{projectResp.GetId()}}, } response.Projects[0] = &project.Project{ Id: projectResp.GetId(), @@ -892,7 +890,7 @@ func TestServer_ListProjects_PermissionV2(t *testing.T) { // projectGrantResp := instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgID) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ProjectIds: []string{projectResp.GetId()}}, + InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{projectResp.GetId()}}, } /* response.Projects[0] = &project.Project{ @@ -1227,9 +1225,7 @@ func TestServer_ListProjectGrants(t *testing.T) { project2Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) project3Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &project.InProjectIDsFilter{ - ProjectIds: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId(), projectResp.GetId()}, - }, + InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId(), projectResp.GetId()}}, } createProjectGrant(iamOwnerCtx, instance, t, orgID, project1Resp.GetId(), name1) diff --git a/internal/integration/client.go b/internal/integration/client.go index 838a728faf..20c98b5628 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -16,6 +16,7 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration/scim" @@ -271,7 +272,8 @@ func (i *Instance) CreateUserTypeMachine(ctx context.Context) *user_v2.CreateUse func (i *Instance) CreatePersonalAccessToken(ctx context.Context, userID string) *user_v2.AddPersonalAccessTokenResponse { resp, err := i.Client.UserV2.AddPersonalAccessToken(ctx, &user_v2.AddPersonalAccessTokenRequest{ - UserId: userID, + UserId: userID, + ExpirationDate: timestamppb.New(time.Now().Add(30 * time.Minute)), }) logging.OnError(err).Panic("create pat") return resp From 4df138286b673255319b20b685f1cba31c05b47d Mon Sep 17 00:00:00 2001 From: Silvan <27845747+adlerhurst@users.noreply.github.com> Date: Fri, 6 Jun 2025 10:48:29 +0200 Subject: [PATCH 093/123] perf(query): reduce user query duration (#10037) # Which Problems Are Solved The resource usage to query user(s) on the database was high and therefore could have performance impact. # How the Problems Are Solved Database queries involving the users and loginnames table were improved and an index was added for user by email query. # Additional Changes - spellchecks - updated apis on load tests # additional info needs cherry pick to v3 --- cmd/setup/58.go | 49 +++ cmd/setup/58/01_update_login_names3_view.sql | 36 ++ cmd/setup/58/02_create_index.sql | 1 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + internal/query/oidc_settings.go | 2 +- internal/query/org_metadata.go | 4 +- internal/query/project.go | 2 +- internal/query/project_grant.go | 4 +- internal/query/projection/login_name.go | 111 +----- .../query/projection/login_name_query.sql | 35 ++ internal/query/projection/user.go | 1 + internal/query/restrictions.go | 2 +- internal/query/search_query.go | 13 +- internal/query/search_query_test.go | 22 +- internal/query/secret_generators.go | 2 +- internal/query/security_policy.go | 2 +- internal/query/user.go | 158 ++------ internal/query/user_by_id.sql | 52 +-- internal/query/user_metadata.go | 6 +- internal/query/user_notify_by_id.sql | 52 +-- internal/query/user_personal_access_token.go | 2 +- internal/query/user_test.go | 345 +----------------- load-test/src/org.ts | 2 +- load-test/src/use_cases/manipulate_user.ts | 2 +- load-test/src/user.ts | 6 +- 26 files changed, 225 insertions(+), 689 deletions(-) create mode 100644 cmd/setup/58.go create mode 100644 cmd/setup/58/01_update_login_names3_view.sql create mode 100644 cmd/setup/58/02_create_index.sql create mode 100644 internal/query/projection/login_name_query.sql diff --git a/cmd/setup/58.go b/cmd/setup/58.go new file mode 100644 index 0000000000..c46b30f548 --- /dev/null +++ b/cmd/setup/58.go @@ -0,0 +1,49 @@ +package setup + +import ( + "context" + "database/sql" + "embed" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 58/*.sql + replaceLoginNames3View embed.FS +) + +type ReplaceLoginNames3View struct { + dbClient *database.DB +} + +func (mig *ReplaceLoginNames3View) Execute(ctx context.Context, _ eventstore.Event) error { + var exists bool + err := mig.dbClient.QueryRowContext(ctx, func(r *sql.Row) error { + return r.Scan(&exists) + }, "SELECT exists(SELECT 1 from information_schema.views WHERE table_schema = 'projections' AND table_name = 'login_names3')") + + if err != nil || !exists { + return err + } + + statements, err := readStatements(replaceLoginNames3View, "58") + if err != nil { + return err + } + for _, stmt := range statements { + logging.WithFields("file", stmt.file, "migration", mig.String()).Info("execute statement") + if _, err := mig.dbClient.ExecContext(ctx, stmt.query); err != nil { + return fmt.Errorf("%s %s: %w", mig.String(), stmt.file, err) + } + } + return nil +} + +func (mig *ReplaceLoginNames3View) String() string { + return "58_replace_login_names3_view" +} diff --git a/cmd/setup/58/01_update_login_names3_view.sql b/cmd/setup/58/01_update_login_names3_view.sql new file mode 100644 index 0000000000..4499296152 --- /dev/null +++ b/cmd/setup/58/01_update_login_names3_view.sql @@ -0,0 +1,36 @@ +CREATE OR REPLACE VIEW projections.login_names3 AS + SELECT + u.id AS user_id + , CASE + WHEN p.must_be_domain THEN CONCAT(u.user_name, '@', d.name) + ELSE u.user_name + END AS login_name + , COALESCE(d.is_primary, TRUE) AS is_primary + , u.instance_id + FROM + projections.login_names3_users AS u + LEFT JOIN LATERAL ( + SELECT + must_be_domain + , is_default + FROM + projections.login_names3_policies AS p + WHERE + ( + p.instance_id = u.instance_id + AND NOT p.is_default + AND p.resource_owner = u.resource_owner + ) OR ( + p.instance_id = u.instance_id + AND p.is_default + ) + ORDER BY + p.is_default -- custom first + LIMIT 1 + ) AS p ON TRUE + LEFT JOIN + projections.login_names3_domains d + ON + p.must_be_domain + AND u.resource_owner = d.resource_owner + AND u.instance_id = d.instance_id diff --git a/cmd/setup/58/02_create_index.sql b/cmd/setup/58/02_create_index.sql new file mode 100644 index 0000000000..ed3627b427 --- /dev/null +++ b/cmd/setup/58/02_create_index.sql @@ -0,0 +1 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS login_names3_policies_is_default_owner_idx ON projections.login_names3_policies (instance_id, is_default, resource_owner) INCLUDE (must_be_domain) diff --git a/cmd/setup/config.go b/cmd/setup/config.go index dd59ba3f07..0c3f726902 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -154,6 +154,7 @@ type Steps struct { s55ExecutionHandlerStart *ExecutionHandlerStart s56IDPTemplate6SAMLFederatedLogout *IDPTemplate6SAMLFederatedLogout s57CreateResourceCounts *CreateResourceCounts + s58ReplaceLoginNames3View *ReplaceLoginNames3View } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 1465180a6b..8ee8d7fc68 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -216,6 +216,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s55ExecutionHandlerStart = &ExecutionHandlerStart{dbClient: dbClient} steps.s56IDPTemplate6SAMLFederatedLogout = &IDPTemplate6SAMLFederatedLogout{dbClient: dbClient} steps.s57CreateResourceCounts = &CreateResourceCounts{dbClient: dbClient} + steps.s58ReplaceLoginNames3View = &ReplaceLoginNames3View{dbClient: dbClient} err = projection.Create(ctx, dbClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -262,6 +263,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s55ExecutionHandlerStart, steps.s56IDPTemplate6SAMLFederatedLogout, steps.s57CreateResourceCounts, + steps.s58ReplaceLoginNames3View, } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/internal/query/oidc_settings.go b/internal/query/oidc_settings.go index bdd21cfd15..4ecd6cdad2 100644 --- a/internal/query/oidc_settings.go +++ b/internal/query/oidc_settings.go @@ -84,7 +84,7 @@ func (q *Queries) OIDCSettingsByAggID(ctx context.Context, aggregateID string) ( OIDCSettingsColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), }).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-s9nle", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-s9nle", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { diff --git a/internal/query/org_metadata.go b/internal/query/org_metadata.go index fe61ad51d9..e67c7222cd 100644 --- a/internal/query/org_metadata.go +++ b/internal/query/org_metadata.go @@ -103,7 +103,7 @@ func (q *Queries) GetOrgMetadataByKey(ctx context.Context, shouldTriggerBulk boo } stmt, args, err := query.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-aDaG2", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-aDaG2", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -133,7 +133,7 @@ func (q *Queries) SearchOrgMetadata(ctx context.Context, shouldTriggerBulk bool, query, scan := prepareOrgMetadataListQuery() stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Egbld", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Egbld", "Errors.Query.SQLStatement") } err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { diff --git a/internal/query/project.go b/internal/query/project.go index 728731f7cb..59e2dd95c0 100644 --- a/internal/query/project.go +++ b/internal/query/project.go @@ -211,7 +211,7 @@ func (q *Queries) ProjectByID(ctx context.Context, shouldTriggerBulk bool, id st } query, args, err := stmt.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-2m00Q", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-2m00Q", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { diff --git a/internal/query/project_grant.go b/internal/query/project_grant.go index 3093d26f30..1931cad0f5 100644 --- a/internal/query/project_grant.go +++ b/internal/query/project_grant.go @@ -167,7 +167,7 @@ func (q *Queries) ProjectGrantByID(ctx context.Context, shouldTriggerBulk bool, } query, args, err := stmt.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Nf93d", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Nf93d", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -189,7 +189,7 @@ func (q *Queries) ProjectGrantByIDAndGrantedOrg(ctx context.Context, id, granted } query, args, err := stmt.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-MO9fs", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-MO9fs", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { diff --git a/internal/query/projection/login_name.go b/internal/query/projection/login_name.go index 3c31928af4..e60f725dc7 100644 --- a/internal/query/projection/login_name.go +++ b/internal/query/projection/login_name.go @@ -2,9 +2,7 @@ package projection import ( "context" - "strings" - - sq "github.com/Masterminds/squirrel" + _ "embed" "github.com/zitadel/zitadel/internal/eventstore" old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" @@ -58,105 +56,8 @@ const ( LoginNamePoliciesInstanceIDCol = "instance_id" ) -var ( - policyUsers = sq.Select( - alias( - col(usersAlias, LoginNameUserIDCol), - LoginNameUserCol, - ), - col(usersAlias, LoginNameUserUserNameCol), - col(usersAlias, LoginNameUserInstanceIDCol), - col(usersAlias, LoginNameUserResourceOwnerCol), - alias( - coalesce(col(policyCustomAlias, LoginNamePoliciesMustBeDomainCol), col(policyDefaultAlias, LoginNamePoliciesMustBeDomainCol)), - LoginNamePoliciesMustBeDomainCol, - ), - ).From(alias(LoginNameUserProjectionTable, usersAlias)). - LeftJoin( - leftJoin(LoginNamePolicyProjectionTable, policyCustomAlias, - eq(col(policyCustomAlias, LoginNamePoliciesResourceOwnerCol), col(usersAlias, LoginNameUserResourceOwnerCol)), - eq(col(policyCustomAlias, LoginNamePoliciesInstanceIDCol), col(usersAlias, LoginNameUserInstanceIDCol)), - ), - ). - LeftJoin( - leftJoin(LoginNamePolicyProjectionTable, policyDefaultAlias, - eq(col(policyDefaultAlias, LoginNamePoliciesIsDefaultCol), "true"), - eq(col(policyDefaultAlias, LoginNamePoliciesInstanceIDCol), col(usersAlias, LoginNameUserInstanceIDCol)), - ), - ) - - loginNamesTable = sq.Select( - col(policyUsersAlias, LoginNameUserCol), - col(policyUsersAlias, LoginNameUserUserNameCol), - col(policyUsersAlias, LoginNameUserResourceOwnerCol), - alias(col(policyUsersAlias, LoginNameUserInstanceIDCol), - LoginNameInstanceIDCol), - col(policyUsersAlias, LoginNamePoliciesMustBeDomainCol), - alias(col(domainsAlias, LoginNameDomainNameCol), - domainAlias), - col(domainsAlias, LoginNameDomainIsPrimaryCol), - ).FromSelect(policyUsers, policyUsersAlias). - LeftJoin( - leftJoin(LoginNameDomainProjectionTable, domainsAlias, - col(policyUsersAlias, LoginNamePoliciesMustBeDomainCol), - eq(col(policyUsersAlias, LoginNameUserResourceOwnerCol), col(domainsAlias, LoginNameDomainResourceOwnerCol)), - eq(col(policyUsersAlias, LoginNamePoliciesInstanceIDCol), col(domainsAlias, LoginNameDomainInstanceIDCol)), - ), - ) - - viewStmt, _ = sq.Select( - LoginNameUserCol, - alias( - whenThenElse( - LoginNamePoliciesMustBeDomainCol, - concat(LoginNameUserUserNameCol, "'@'", domainAlias), - LoginNameUserUserNameCol), - LoginNameCol), - alias(coalesce(LoginNameDomainIsPrimaryCol, "true"), - LoginNameIsPrimaryCol), - LoginNameInstanceIDCol, - ).FromSelect(loginNamesTable, LoginNameTableAlias).MustSql() -) - -func col(table, name string) string { - return table + "." + name -} - -func alias(col, alias string) string { - return col + " AS " + alias -} - -func coalesce(values ...string) string { - str := "COALESCE(" - for i, value := range values { - if i > 0 { - str += ", " - } - str += value - } - str += ")" - return str -} - -func eq(first, second string) string { - return first + " = " + second -} - -func leftJoin(table, alias, on string, and ...string) string { - st := table + " " + alias + " ON " + on - for _, a := range and { - st += " AND " + a - } - return st -} - -func concat(strs ...string) string { - return "CONCAT(" + strings.Join(strs, ", ") + ")" -} - -func whenThenElse(when, then, el string) string { - return "(CASE WHEN " + when + " THEN " + then + " ELSE " + el + " END)" -} +//go:embed login_name_query.sql +var loginNameViewStmt string type loginNameProjection struct{} @@ -170,7 +71,7 @@ func (*loginNameProjection) Name() string { func (*loginNameProjection) Init() *old_handler.Check { return handler.NewViewCheck( - viewStmt, + loginNameViewStmt, handler.NewSuffixedTable( []*handler.InitColumn{ handler.NewColumn(LoginNameUserIDCol, handler.ColumnTypeText), @@ -229,7 +130,9 @@ func (*loginNameProjection) Init() *old_handler.Check { }, handler.NewPrimaryKey(LoginNamePoliciesInstanceIDCol, LoginNamePoliciesResourceOwnerCol), loginNamePolicySuffix, - handler.WithIndex(handler.NewIndex("is_default", []string{LoginNamePoliciesResourceOwnerCol, LoginNamePoliciesIsDefaultCol})), + // this index is not used anymore, but kept for understanding why the default exists on existing systems, TODO: remove in login_names4 + // handler.WithIndex(handler.NewIndex("is_default", []string{LoginNamePoliciesResourceOwnerCol, LoginNamePoliciesIsDefaultCol})), + handler.WithIndex(handler.NewIndex("is_default_owner", []string{LoginNamePoliciesInstanceIDCol, LoginNamePoliciesIsDefaultCol, LoginNamePoliciesResourceOwnerCol}, handler.WithInclude(LoginNamePoliciesMustBeDomainCol))), ), ) } diff --git a/internal/query/projection/login_name_query.sql b/internal/query/projection/login_name_query.sql new file mode 100644 index 0000000000..89dc803feb --- /dev/null +++ b/internal/query/projection/login_name_query.sql @@ -0,0 +1,35 @@ +SELECT + u.id AS user_id + , CASE + WHEN p.must_be_domain THEN CONCAT(u.user_name, '@', d.name) + ELSE u.user_name + END AS login_name + , COALESCE(d.is_primary, TRUE) AS is_primary + , u.instance_id +FROM + projections.login_names3_users AS u +LEFT JOIN LATERAL ( + SELECT + must_be_domain + , is_default + FROM + projections.login_names3_policies AS p + WHERE + ( + p.instance_id = u.instance_id + AND NOT p.is_default + AND p.resource_owner = u.resource_owner + ) OR ( + p.instance_id = u.instance_id + AND p.is_default + ) + ORDER BY + p.is_default -- custom first + LIMIT 1 +) AS p ON TRUE +LEFT JOIN + projections.login_names3_domains d + ON + p.must_be_domain + AND u.resource_owner = d.resource_owner + AND u.instance_id = d.instance_id \ No newline at end of file diff --git a/internal/query/projection/user.go b/internal/query/projection/user.go index f1e0613287..d11c4855f7 100644 --- a/internal/query/projection/user.go +++ b/internal/query/projection/user.go @@ -124,6 +124,7 @@ func (*userProjection) Init() *old_handler.Check { handler.NewPrimaryKey(HumanUserInstanceIDCol, HumanUserIDCol), UserHumanSuffix, handler.WithForeignKey(handler.NewForeignKeyOfPublicKeys()), + handler.WithIndex(handler.NewIndex("email", []string{HumanUserInstanceIDCol, "LOWER(" + HumanEmailCol + ")"})), ), handler.NewSuffixedTable([]*handler.InitColumn{ handler.NewColumn(MachineUserIDCol, handler.ColumnTypeText), diff --git a/internal/query/restrictions.go b/internal/query/restrictions.go index 8cff5737f7..93d435278c 100644 --- a/internal/query/restrictions.go +++ b/internal/query/restrictions.go @@ -78,7 +78,7 @@ func (q *Queries) GetInstanceRestrictions(ctx context.Context) (restrictions Res RestrictionsColumnResourceOwner.identifier(): instanceID, }).ToSql() if err != nil { - return restrictions, zitade_errors.ThrowInternal(err, "QUERY-XnLMQ", "Errors.Query.SQLStatment") + return restrictions, zitade_errors.ThrowInternal(err, "QUERY-XnLMQ", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { restrictions, err = scan(row) diff --git a/internal/query/search_query.go b/internal/query/search_query.go index d5e09027c4..d6dd710d1e 100644 --- a/internal/query/search_query.go +++ b/internal/query/search_query.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "reflect" + "strings" "time" sq "github.com/Masterminds/squirrel" @@ -334,23 +335,23 @@ func (q *textQuery) comp() sq.Sqlizer { case TextNotEquals: return sq.NotEq{q.Column.identifier(): q.Text} case TextEqualsIgnoreCase: - return sq.ILike{q.Column.identifier(): q.Text} + return sq.Like{"LOWER(" + q.Column.identifier() + ")": strings.ToLower(q.Text)} case TextNotEqualsIgnoreCase: - return sq.NotILike{q.Column.identifier(): q.Text} + return sq.NotLike{"LOWER(" + q.Column.identifier() + ")": strings.ToLower(q.Text)} case TextStartsWith: return sq.Like{q.Column.identifier(): q.Text + "%"} case TextStartsWithIgnoreCase: - return sq.ILike{q.Column.identifier(): q.Text + "%"} + return sq.Like{"LOWER(" + q.Column.identifier() + ")": strings.ToLower(q.Text) + "%"} case TextEndsWith: return sq.Like{q.Column.identifier(): "%" + q.Text} case TextEndsWithIgnoreCase: - return sq.ILike{q.Column.identifier(): "%" + q.Text} + return sq.Like{"LOWER(" + q.Column.identifier() + ")": "%" + strings.ToLower(q.Text)} case TextContains: return sq.Like{q.Column.identifier(): "%" + q.Text + "%"} case TextContainsIgnoreCase: - return sq.ILike{q.Column.identifier(): "%" + q.Text + "%"} + return sq.Like{"LOWER(" + q.Column.identifier() + ")": "%" + strings.ToLower(q.Text) + "%"} case TextListContains: - return &listContains{col: q.Column, args: []interface{}{q.Text}} + return &listContains{col: q.Column, args: []any{q.Text}} case textCompareMax: return nil } diff --git a/internal/query/search_query_test.go b/internal/query/search_query_test.go index 13142a0158..7f6672b279 100644 --- a/internal/query/search_query_test.go +++ b/internal/query/search_query_test.go @@ -1204,7 +1204,7 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextEqualsIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "Hurst"}, + query: sq.Like{"LOWER(test_table.test_col)": "hurst"}, }, }, { @@ -1226,7 +1226,7 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextNotEqualsIgnoreCase, }, want: want{ - query: sq.NotILike{"test_table.test_col": "Hurst"}, + query: sq.NotLike{"LOWER(test_table.test_col)": "hurst"}, }, }, { @@ -1237,7 +1237,7 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextEqualsIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "Hu\\%\\%rst"}, + query: sq.Like{"LOWER(test_table.test_col)": "hu\\%\\%rst"}, }, }, { @@ -1270,7 +1270,7 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextStartsWithIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "Hurst%"}, + query: sq.Like{"LOWER(test_table.test_col)": "hurst%"}, }, }, { @@ -1281,7 +1281,7 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextStartsWithIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "Hurst\\%%"}, + query: sq.Like{"LOWER(test_table.test_col)": "hurst\\%%"}, }, }, { @@ -1314,7 +1314,7 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextEndsWithIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "%Hurst"}, + query: sq.Like{"LOWER(test_table.test_col)": "%hurst"}, }, }, { @@ -1325,7 +1325,7 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextEndsWithIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "%\\%Hurst"}, + query: sq.Like{"LOWER(test_table.test_col)": "%\\%hurst"}, }, }, { @@ -1351,14 +1351,14 @@ func TestTextQuery_comp(t *testing.T) { }, }, { - name: "containts ignore case", + name: "contains ignore case", fields: fields{ Column: testCol, Text: "Hurst", Compare: TextContainsIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "%Hurst%"}, + query: sq.Like{"LOWER(test_table.test_col)": "%hurst%"}, }, }, { @@ -1369,11 +1369,11 @@ func TestTextQuery_comp(t *testing.T) { Compare: TextContainsIgnoreCase, }, want: want{ - query: sq.ILike{"test_table.test_col": "%\\%Hurst\\%%"}, + query: sq.Like{"LOWER(test_table.test_col)": "%\\%hurst\\%%"}, }, }, { - name: "list containts", + name: "list contains", fields: fields{ Column: testCol, Text: "Hurst", diff --git a/internal/query/secret_generators.go b/internal/query/secret_generators.go index c267d7b290..ca77bc35b5 100644 --- a/internal/query/secret_generators.go +++ b/internal/query/secret_generators.go @@ -132,7 +132,7 @@ func (q *Queries) SecretGeneratorByType(ctx context.Context, generatorType domai SecretGeneratorColumnInstanceID.identifier(): instanceID, }).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-3k99f", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-3k99f", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { diff --git a/internal/query/security_policy.go b/internal/query/security_policy.go index 7a3fb3fa89..5a2450258e 100644 --- a/internal/query/security_policy.go +++ b/internal/query/security_policy.go @@ -67,7 +67,7 @@ func (q *Queries) SecurityPolicy(ctx context.Context) (policy *SecurityPolicy, e SecurityPolicyColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), }).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Sf6d1", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Sf6d1", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { diff --git a/internal/query/user.go b/internal/query/user.go index 6844982f07..ac3eb79fc9 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -200,21 +200,15 @@ var ( userLoginNamesTable = loginNameTable.setAlias("login_names") userLoginNamesUserIDCol = LoginNameUserIDCol.setTable(userLoginNamesTable) - userLoginNamesNameCol = LoginNameNameCol.setTable(userLoginNamesTable) userLoginNamesInstanceIDCol = LoginNameInstanceIDCol.setTable(userLoginNamesTable) userLoginNamesListCol = Column{ - name: "loginnames", + name: "login_names", table: userLoginNamesTable, } - userLoginNamesLowerListCol = Column{ - name: "loginnames_lower", + userPreferredLoginNameCol = Column{ + name: "preferred_login_name", table: userLoginNamesTable, } - userPreferredLoginNameTable = loginNameTable.setAlias("preferred_login_name") - userPreferredLoginNameUserIDCol = LoginNameUserIDCol.setTable(userPreferredLoginNameTable) - userPreferredLoginNameCol = LoginNameNameCol.setTable(userPreferredLoginNameTable) - userPreferredLoginNameIsPrimaryCol = LoginNameIsPrimaryCol.setTable(userPreferredLoginNameTable) - userPreferredLoginNameInstanceIDCol = LoginNameInstanceIDCol.setTable(userPreferredLoginNameTable) ) var ( @@ -459,7 +453,7 @@ func (q *Queries) GetHumanProfile(ctx context.Context, userID string, queries .. } stmt, args, err := query.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dgbg2", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Dgbg2", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -483,7 +477,7 @@ func (q *Queries) GetHumanEmail(ctx context.Context, userID string, queries ...S } stmt, args, err := query.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-BHhj3", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-BHhj3", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -507,7 +501,7 @@ func (q *Queries) GetHumanPhone(ctx context.Context, userID string, queries ...S } stmt, args, err := query.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dg43g", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Dg43g", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -593,7 +587,7 @@ func (q *Queries) GetNotifyUser(ctx context.Context, shouldTriggered bool, queri } stmt, args, err := query.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Err3g", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Err3g", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -611,7 +605,7 @@ func (q *Queries) CountUsers(ctx context.Context, queries *UserSearchQueries) (c eq := sq.Eq{UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { - return 0, zerrors.ThrowInternal(err, "QUERY-w3Dx", "Errors.Query.SQLStatment") + return 0, zerrors.ThrowInternal(err, "QUERY-w3Dx", "Errors.Query.SQLStatement") } err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { @@ -646,7 +640,7 @@ func (q *Queries) searchUsers(ctx context.Context, queries *UserSearchQueries, p UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), }).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dgbg2", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Dgbg2", "Errors.Query.SQLStatement") } err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { @@ -693,7 +687,7 @@ func (q *Queries) IsUserUnique(ctx context.Context, username, email, resourceOwn eq := sq.Eq{UserInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := query.Where(eq).ToSql() if err != nil { - return false, zerrors.ThrowInternal(err, "QUERY-Dg43g", "Errors.Query.SQLStatment") + return false, zerrors.ThrowInternal(err, "QUERY-Dg43g", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -792,12 +786,8 @@ func NewUserPreferredLoginNameSearchQuery(value string, comparison TextCompariso return NewTextQuery(userPreferredLoginNameCol, value, comparison) } -func NewUserLoginNamesSearchQuery(value string) (SearchQuery, error) { - return NewTextQuery(userLoginNamesLowerListCol, strings.ToLower(value), TextListContains) -} - func NewUserLoginNameExistsQuery(value string, comparison TextComparison) (SearchQuery, error) { - // linking queries for the subselect + // linking queries for the sub select instanceQuery, err := NewColumnComparisonQuery(LoginNameInstanceIDCol, UserInstanceIDCol, ColumnEquals) if err != nil { return nil, err @@ -828,30 +818,16 @@ func triggerUserProjections(ctx context.Context) { triggerBatch(ctx, projection.UserProjection, projection.LoginNameProjection) } -func prepareLoginNamesQuery() (string, []interface{}, error) { - return sq.Select( - userLoginNamesUserIDCol.identifier(), - "ARRAY_AGG("+userLoginNamesNameCol.identifier()+")::TEXT[] AS "+userLoginNamesListCol.name, - "ARRAY_AGG(LOWER("+userLoginNamesNameCol.identifier()+"))::TEXT[] AS "+userLoginNamesLowerListCol.name, - userLoginNamesInstanceIDCol.identifier(), - ).From(userLoginNamesTable.identifier()). - GroupBy( - userLoginNamesUserIDCol.identifier(), - userLoginNamesInstanceIDCol.identifier(), - ).ToSql() -} - -func preparePreferredLoginNamesQuery() (string, []interface{}, error) { - return sq.Select( - userPreferredLoginNameUserIDCol.identifier(), - userPreferredLoginNameCol.identifier(), - userPreferredLoginNameInstanceIDCol.identifier(), - ).From(userPreferredLoginNameTable.identifier()). - Where(sq.Eq{ - userPreferredLoginNameIsPrimaryCol.identifier(): true, - }, - ).ToSql() -} +var joinLoginNames = `LEFT JOIN LATERAL (` + + `SELECT` + + ` ARRAY_AGG(ln.login_name ORDER BY ln.login_name) AS login_names,` + + ` MAX(CASE WHEN ln.is_primary THEN ln.login_name ELSE NULL END) AS preferred_login_name` + + ` FROM` + + ` projections.login_names3 AS ln` + + ` WHERE` + + ` ln.user_id = ` + UserIDCol.identifier() + + ` AND ln.instance_id = ` + UserInstanceIDCol.identifier() + + `) AS login_names ON TRUE` func scanUser(row *sql.Row) (*User, error) { u := new(User) @@ -951,64 +927,6 @@ func scanUser(row *sql.Row) (*User, error) { return u, nil } -func prepareUserQuery() (sq.SelectBuilder, func(*sql.Row) (*User, error)) { - loginNamesQuery, loginNamesArgs, err := prepareLoginNamesQuery() - if err != nil { - return sq.SelectBuilder{}, nil - } - preferredLoginNameQuery, preferredLoginNameArgs, err := preparePreferredLoginNamesQuery() - if err != nil { - return sq.SelectBuilder{}, nil - } - return sq.Select( - UserIDCol.identifier(), - UserCreationDateCol.identifier(), - UserChangeDateCol.identifier(), - UserResourceOwnerCol.identifier(), - UserSequenceCol.identifier(), - UserStateCol.identifier(), - UserTypeCol.identifier(), - UserUsernameCol.identifier(), - userLoginNamesListCol.identifier(), - userPreferredLoginNameCol.identifier(), - HumanUserIDCol.identifier(), - HumanFirstNameCol.identifier(), - HumanLastNameCol.identifier(), - HumanNickNameCol.identifier(), - HumanDisplayNameCol.identifier(), - HumanPreferredLanguageCol.identifier(), - HumanGenderCol.identifier(), - HumanAvatarURLCol.identifier(), - HumanEmailCol.identifier(), - HumanIsEmailVerifiedCol.identifier(), - HumanPhoneCol.identifier(), - HumanIsPhoneVerifiedCol.identifier(), - HumanPasswordChangeRequiredCol.identifier(), - HumanPasswordChangedCol.identifier(), - HumanMFAInitSkippedCol.identifier(), - MachineUserIDCol.identifier(), - MachineNameCol.identifier(), - MachineDescriptionCol.identifier(), - MachineSecretCol.identifier(), - MachineAccessTokenTypeCol.identifier(), - countColumn.identifier(), - ). - From(userTable.identifier()). - LeftJoin(join(HumanUserIDCol, UserIDCol)). - LeftJoin(join(MachineUserIDCol, UserIDCol)). - LeftJoin("("+loginNamesQuery+") AS "+userLoginNamesTable.alias+" ON "+ - userLoginNamesUserIDCol.identifier()+" = "+UserIDCol.identifier()+" AND "+ - userLoginNamesInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier(), - loginNamesArgs...). - LeftJoin("("+preferredLoginNameQuery+") AS "+userPreferredLoginNameTable.alias+" ON "+ - userPreferredLoginNameUserIDCol.identifier()+" = "+UserIDCol.identifier()+" AND "+ - userPreferredLoginNameInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier(), - preferredLoginNameArgs...). - PlaceholderFormat(sq.Dollar), - - scanUser -} - func prepareProfileQuery() (sq.SelectBuilder, func(*sql.Row) (*Profile, error)) { return sq.Select( UserIDCol.identifier(), @@ -1170,14 +1088,6 @@ func preparePhoneQuery() (sq.SelectBuilder, func(*sql.Row) (*Phone, error)) { } func prepareNotifyUserQuery() (sq.SelectBuilder, func(*sql.Row) (*NotifyUser, error)) { - loginNamesQuery, loginNamesArgs, err := prepareLoginNamesQuery() - if err != nil { - return sq.SelectBuilder{}, nil - } - preferredLoginNameQuery, preferredLoginNameArgs, err := preparePreferredLoginNamesQuery() - if err != nil { - return sq.SelectBuilder{}, nil - } return sq.Select( UserIDCol.identifier(), UserCreationDateCol.identifier(), @@ -1208,14 +1118,7 @@ func prepareNotifyUserQuery() (sq.SelectBuilder, func(*sql.Row) (*NotifyUser, er From(userTable.identifier()). LeftJoin(join(HumanUserIDCol, UserIDCol)). LeftJoin(join(NotifyUserIDCol, UserIDCol)). - LeftJoin("("+loginNamesQuery+") AS "+userLoginNamesTable.alias+" ON "+ - userLoginNamesUserIDCol.identifier()+" = "+UserIDCol.identifier()+" AND "+ - userLoginNamesInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier(), - loginNamesArgs...). - LeftJoin("("+preferredLoginNameQuery+") AS "+userPreferredLoginNameTable.alias+" ON "+ - userPreferredLoginNameUserIDCol.identifier()+" = "+UserIDCol.identifier()+" AND "+ - userPreferredLoginNameInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier(), - preferredLoginNameArgs...). + JoinClause(joinLoginNames). PlaceholderFormat(sq.Dollar), scanNotifyUser } @@ -1359,14 +1262,6 @@ func prepareUserUniqueQuery() (sq.SelectBuilder, func(*sql.Row) (bool, error)) { } func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) { - loginNamesQuery, loginNamesArgs, err := prepareLoginNamesQuery() - if err != nil { - return sq.SelectBuilder{}, nil - } - preferredLoginNameQuery, preferredLoginNameArgs, err := preparePreferredLoginNamesQuery() - if err != nil { - return sq.SelectBuilder{}, nil - } return sq.Select( UserIDCol.identifier(), UserCreationDateCol.identifier(), @@ -1401,14 +1296,7 @@ func prepareUsersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Users, error)) { From(userTable.identifier()). LeftJoin(join(HumanUserIDCol, UserIDCol)). LeftJoin(join(MachineUserIDCol, UserIDCol)). - LeftJoin("("+loginNamesQuery+") AS "+userLoginNamesTable.alias+" ON "+ - userLoginNamesUserIDCol.identifier()+" = "+UserIDCol.identifier()+" AND "+ - userLoginNamesInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier(), - loginNamesArgs...). - LeftJoin("("+preferredLoginNameQuery+") AS "+userPreferredLoginNameTable.alias+" ON "+ - userPreferredLoginNameUserIDCol.identifier()+" = "+UserIDCol.identifier()+" AND "+ - userPreferredLoginNameInstanceIDCol.identifier()+" = "+UserInstanceIDCol.identifier(), - preferredLoginNameArgs...). + JoinClause(joinLoginNames). PlaceholderFormat(sq.Dollar), func(rows *sql.Rows) (*Users, error) { users := make([]*User, 0) diff --git a/internal/query/user_by_id.sql b/internal/query/user_by_id.sql index 2ce741f9b7..a89e701698 100644 --- a/internal/query/user_by_id.sql +++ b/internal/query/user_by_id.sql @@ -1,41 +1,3 @@ -WITH login_names AS (SELECT - u.id user_id - , u.instance_id - , u.resource_owner - , u.user_name - , d.name domain_name - , d.is_primary - , p.must_be_domain - , CASE WHEN p.must_be_domain - THEN concat(u.user_name, '@', d.name) - ELSE u.user_name - END login_name - FROM - projections.login_names3_users u - JOIN lateral ( - SELECT - p.must_be_domain - FROM - projections.login_names3_policies p - WHERE - u.instance_id = p.instance_id - AND ( - (p.is_default IS TRUE AND p.instance_id = $3) - OR (p.instance_id = $3 AND p.resource_owner = u.resource_owner) - ) - ORDER BY is_default - LIMIT 1 - ) p ON TRUE - JOIN - projections.login_names3_domains d - ON - u.instance_id = d.instance_id - AND u.resource_owner = d.resource_owner - WHERE - u.id = $1 - AND (u.resource_owner = $2 OR $2 = '') - AND u.instance_id = $3 -) SELECT u.id , u.creation_date @@ -45,8 +7,8 @@ SELECT , u.state , u.type , u.username - , (SELECT array_agg(ln.login_name)::TEXT[] login_names FROM login_names ln GROUP BY ln.user_id, ln.instance_id) login_names - , (SELECT ln.login_name login_names_lower FROM login_names ln WHERE ln.is_primary IS TRUE) preferred_login_name + , login_names.login_names AS login_names + , login_names.preferred_login_name AS preferred_login_name , h.user_id , h.first_name , h.last_name @@ -79,6 +41,16 @@ LEFT JOIN ON u.id = m.user_id AND u.instance_id = m.instance_id +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(ln.login_name ORDER BY ln.login_name) AS login_names, + MAX(CASE WHEN ln.is_primary THEN ln.login_name ELSE NULL END) AS preferred_login_name + FROM + projections.login_names3 AS ln + WHERE + ln.user_id = u.id + AND ln.instance_id = u.instance_id +) AS login_names ON TRUE WHERE u.id = $1 AND (u.resource_owner = $2 OR $2 = '') diff --git a/internal/query/user_metadata.go b/internal/query/user_metadata.go index ff612f82c8..534c707593 100644 --- a/internal/query/user_metadata.go +++ b/internal/query/user_metadata.go @@ -97,7 +97,7 @@ func (q *Queries) GetUserMetadataByKey(ctx context.Context, shouldTriggerBulk bo } stmt, args, err := query.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-aDGG2", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-aDGG2", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { @@ -125,7 +125,7 @@ func (q *Queries) SearchUserMetadataForUsers(ctx context.Context, shouldTriggerB } stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Egbgd", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Egbgd", "Errors.Query.SQLStatement") } err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { @@ -157,7 +157,7 @@ func (q *Queries) SearchUserMetadata(ctx context.Context, shouldTriggerBulk bool } stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Egbgd", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Egbgd", "Errors.Query.SQLStatement") } err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { diff --git a/internal/query/user_notify_by_id.sql b/internal/query/user_notify_by_id.sql index 10aa60ee60..6322229a91 100644 --- a/internal/query/user_notify_by_id.sql +++ b/internal/query/user_notify_by_id.sql @@ -1,41 +1,3 @@ -WITH login_names AS ( - SELECT - u.id user_id - , u.instance_id - , u.resource_owner - , u.user_name - , d.name domain_name - , d.is_primary - , p.must_be_domain - , CASE WHEN p.must_be_domain - THEN concat(u.user_name, '@', d.name) - ELSE u.user_name - END login_name - FROM - projections.login_names3_users u - JOIN lateral ( - SELECT - p.must_be_domain - FROM - projections.login_names3_policies p - WHERE - u.instance_id = p.instance_id - AND ( - (p.is_default IS TRUE AND p.instance_id = $2) - OR (p.instance_id = $2 AND p.resource_owner = u.resource_owner) - ) - ORDER BY is_default - LIMIT 1 - ) p ON TRUE - JOIN - projections.login_names3_domains d - ON - u.instance_id = d.instance_id - AND u.resource_owner = d.resource_owner - WHERE - u.instance_id = $2 - AND u.id = $1 -) SELECT u.id , u.creation_date @@ -45,8 +7,8 @@ SELECT , u.state , u.type , u.username - , (SELECT array_agg(ln.login_name)::TEXT[] login_names FROM login_names ln GROUP BY ln.user_id, ln.instance_id) login_names - , (SELECT ln.login_name login_names_lower FROM login_names ln WHERE ln.is_primary IS TRUE) preferred_login_name + , login_names.login_names AS login_names + , login_names.preferred_login_name AS preferred_login_name , h.user_id , h.first_name , h.last_name @@ -73,6 +35,16 @@ LEFT JOIN ON u.id = n.user_id AND u.instance_id = n.instance_id +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(ln.login_name ORDER BY ln.login_name) AS login_names, + MAX(CASE WHEN ln.is_primary THEN ln.login_name ELSE NULL END) AS preferred_login_name + FROM + projections.login_names3 AS ln + WHERE + ln.user_id = u.id + AND ln.instance_id = u.instance_id +) AS login_names ON TRUE WHERE u.id = $1 AND u.instance_id = $2 diff --git a/internal/query/user_personal_access_token.go b/internal/query/user_personal_access_token.go index 61d349961c..49281d9f90 100644 --- a/internal/query/user_personal_access_token.go +++ b/internal/query/user_personal_access_token.go @@ -118,7 +118,7 @@ func (q *Queries) PersonalAccessTokenByID(ctx context.Context, shouldTriggerBulk } stmt, args, err := query.Where(eq).ToSql() if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dgfb4", "Errors.Query.SQLStatment") + return nil, zerrors.ThrowInternal(err, "QUERY-Dgfb4", "Errors.Query.SQLStatement") } err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { diff --git a/internal/query/user_test.go b/internal/query/user_test.go index 50d65cc1ec..ae5f6be207 100644 --- a/internal/query/user_test.go +++ b/internal/query/user_test.go @@ -222,87 +222,6 @@ func TestUser_userCheckPermission(t *testing.T) { } var ( - loginNamesQuery = `SELECT login_names.user_id, ARRAY_AGG(login_names.login_name)::TEXT[] AS loginnames, ARRAY_AGG(LOWER(login_names.login_name))::TEXT[] AS loginnames_lower, login_names.instance_id` + - ` FROM projections.login_names3 AS login_names` + - ` GROUP BY login_names.user_id, login_names.instance_id` - preferredLoginNameQuery = `SELECT preferred_login_name.user_id, preferred_login_name.login_name, preferred_login_name.instance_id` + - ` FROM projections.login_names3 AS preferred_login_name` + - ` WHERE preferred_login_name.is_primary = $1` - userQuery = `SELECT projections.users14.id,` + - ` projections.users14.creation_date,` + - ` projections.users14.change_date,` + - ` projections.users14.resource_owner,` + - ` projections.users14.sequence,` + - ` projections.users14.state,` + - ` projections.users14.type,` + - ` projections.users14.username,` + - ` login_names.loginnames,` + - ` preferred_login_name.login_name,` + - ` projections.users14_humans.user_id,` + - ` projections.users14_humans.first_name,` + - ` projections.users14_humans.last_name,` + - ` projections.users14_humans.nick_name,` + - ` projections.users14_humans.display_name,` + - ` projections.users14_humans.preferred_language,` + - ` projections.users14_humans.gender,` + - ` projections.users14_humans.avatar_key,` + - ` projections.users14_humans.email,` + - ` projections.users14_humans.is_email_verified,` + - ` projections.users14_humans.phone,` + - ` projections.users14_humans.is_phone_verified,` + - ` projections.users14_humans.password_change_required,` + - ` projections.users14_humans.password_changed,` + - ` projections.users14_humans.mfa_init_skipped,` + - ` projections.users14_machines.user_id,` + - ` projections.users14_machines.name,` + - ` projections.users14_machines.description,` + - ` projections.users14_machines.secret,` + - ` projections.users14_machines.access_token_type,` + - ` COUNT(*) OVER ()` + - ` FROM projections.users14` + - ` LEFT JOIN projections.users14_humans ON projections.users14.id = projections.users14_humans.user_id AND projections.users14.instance_id = projections.users14_humans.instance_id` + - ` LEFT JOIN projections.users14_machines ON projections.users14.id = projections.users14_machines.user_id AND projections.users14.instance_id = projections.users14_machines.instance_id` + - ` LEFT JOIN` + - ` (` + loginNamesQuery + `) AS login_names` + - ` ON login_names.user_id = projections.users14.id AND login_names.instance_id = projections.users14.instance_id` + - ` LEFT JOIN` + - ` (` + preferredLoginNameQuery + `) AS preferred_login_name` + - ` ON preferred_login_name.user_id = projections.users14.id AND preferred_login_name.instance_id = projections.users14.instance_id` - userCols = []string{ - "id", - "creation_date", - "change_date", - "resource_owner", - "sequence", - "state", - "type", - "username", - "loginnames", - "login_name", - // human - "user_id", - "first_name", - "last_name", - "nick_name", - "display_name", - "preferred_language", - "gender", - "avatar_key", - "email", - "is_email_verified", - "phone", - "is_phone_verified", - "password_change_required", - "password_changed", - "mfa_init_skipped", - // machine - "user_id", - "name", - "description", - "secret", - "access_token_type", - "count", - } profileQuery = `SELECT projections.users14.id,` + ` projections.users14.creation_date,` + ` projections.users14.change_date,` + @@ -397,8 +316,8 @@ var ( ` projections.users14.state,` + ` projections.users14.type,` + ` projections.users14.username,` + - ` login_names.loginnames,` + - ` preferred_login_name.login_name,` + + ` login_names.login_names,` + + ` login_names.preferred_login_name,` + ` projections.users14_humans.user_id,` + ` projections.users14_humans.first_name,` + ` projections.users14_humans.last_name,` + @@ -417,12 +336,7 @@ var ( ` FROM projections.users14` + ` LEFT JOIN projections.users14_humans ON projections.users14.id = projections.users14_humans.user_id AND projections.users14.instance_id = projections.users14_humans.instance_id` + ` LEFT JOIN projections.users14_notifications ON projections.users14.id = projections.users14_notifications.user_id AND projections.users14.instance_id = projections.users14_notifications.instance_id` + - ` LEFT JOIN` + - ` (` + loginNamesQuery + `) AS login_names` + - ` ON login_names.user_id = projections.users14.id AND login_names.instance_id = projections.users14.instance_id` + - ` LEFT JOIN` + - ` (` + preferredLoginNameQuery + `) AS preferred_login_name` + - ` ON preferred_login_name.user_id = projections.users14.id AND preferred_login_name.instance_id = projections.users14.instance_id` + ` LEFT JOIN LATERAL (SELECT ARRAY_AGG(ln.login_name ORDER BY ln.login_name) AS login_names, MAX(CASE WHEN ln.is_primary THEN ln.login_name ELSE NULL END) AS preferred_login_name FROM projections.login_names3 AS ln WHERE ln.user_id = projections.users14.id AND ln.instance_id = projections.users14.instance_id) AS login_names ON TRUE` notifyUserCols = []string{ "id", "creation_date", @@ -432,8 +346,8 @@ var ( "state", "type", "username", - "loginnames", - "login_name", + "login_names", + "preferred_login_name", // human "user_id", "first_name", @@ -460,8 +374,8 @@ var ( ` projections.users14.state,` + ` projections.users14.type,` + ` projections.users14.username,` + - ` login_names.loginnames,` + - ` preferred_login_name.login_name,` + + ` login_names.login_names,` + + ` login_names.preferred_login_name,` + ` projections.users14_humans.user_id,` + ` projections.users14_humans.first_name,` + ` projections.users14_humans.last_name,` + @@ -485,12 +399,7 @@ var ( ` FROM projections.users14` + ` LEFT JOIN projections.users14_humans ON projections.users14.id = projections.users14_humans.user_id AND projections.users14.instance_id = projections.users14_humans.instance_id` + ` LEFT JOIN projections.users14_machines ON projections.users14.id = projections.users14_machines.user_id AND projections.users14.instance_id = projections.users14_machines.instance_id` + - ` LEFT JOIN` + - ` (` + loginNamesQuery + `) AS login_names` + - ` ON login_names.user_id = projections.users14.id AND login_names.instance_id = projections.users14.instance_id` + - ` LEFT JOIN` + - ` (` + preferredLoginNameQuery + `) AS preferred_login_name` + - ` ON preferred_login_name.user_id = projections.users14.id AND preferred_login_name.instance_id = projections.users14.instance_id` + ` LEFT JOIN LATERAL (SELECT ARRAY_AGG(ln.login_name ORDER BY ln.login_name) AS login_names, MAX(CASE WHEN ln.is_primary THEN ln.login_name ELSE NULL END) AS preferred_login_name FROM projections.login_names3 AS ln WHERE ln.user_id = projections.users14.id AND ln.instance_id = projections.users14.instance_id) AS login_names ON TRUE` usersCols = []string{ "id", "creation_date", @@ -500,8 +409,8 @@ var ( "state", "type", "username", - "loginnames", - "login_name", + "login_names", + "preferred_login_name", // human "user_id", "first_name", @@ -540,240 +449,6 @@ func Test_UserPrepares(t *testing.T) { want want object interface{} }{ - { - name: "prepareUserQuery no result", - prepare: prepareUserQuery, - want: want{ - sqlExpectations: mockQueryScanErr( - regexp.QuoteMeta(userQuery), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: (*User)(nil), - }, - { - name: "prepareUserQuery human found", - prepare: prepareUserQuery, - want: want{ - sqlExpectations: mockQuery( - regexp.QuoteMeta(userQuery), - userCols, - []driver.Value{ - "id", - testNow, - testNow, - "resource_owner", - uint64(20211108), - domain.UserStateActive, - domain.UserTypeHuman, - "username", - database.TextArray[string]{"login_name1", "login_name2"}, - "login_name1", - // human - "id", - "first_name", - "last_name", - "nick_name", - "display_name", - "de", - domain.GenderUnspecified, - "avatar_key", - "email", - true, - "phone", - true, - true, - testNow, - testNow, - // machine - nil, - nil, - nil, - nil, - nil, - 1, - }, - ), - }, - object: &User{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - ResourceOwner: "resource_owner", - Sequence: 20211108, - State: domain.UserStateActive, - Type: domain.UserTypeHuman, - Username: "username", - LoginNames: database.TextArray[string]{"login_name1", "login_name2"}, - PreferredLoginName: "login_name1", - Human: &Human{ - FirstName: "first_name", - LastName: "last_name", - NickName: "nick_name", - DisplayName: "display_name", - AvatarKey: "avatar_key", - PreferredLanguage: language.German, - Gender: domain.GenderUnspecified, - Email: "email", - IsEmailVerified: true, - Phone: "phone", - IsPhoneVerified: true, - PasswordChangeRequired: true, - PasswordChanged: testNow, - MFAInitSkipped: testNow, - }, - }, - }, - { - name: "prepareUserQuery machine found", - prepare: prepareUserQuery, - want: want{ - sqlExpectations: mockQuery( - regexp.QuoteMeta(userQuery), - userCols, - []driver.Value{ - "id", - testNow, - testNow, - "resource_owner", - uint64(20211108), - domain.UserStateActive, - domain.UserTypeMachine, - "username", - database.TextArray[string]{"login_name1", "login_name2"}, - "login_name1", - // human - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - // machine - "id", - "name", - "description", - nil, - domain.OIDCTokenTypeBearer, - 1, - }, - ), - }, - object: &User{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - ResourceOwner: "resource_owner", - Sequence: 20211108, - State: domain.UserStateActive, - Type: domain.UserTypeMachine, - Username: "username", - LoginNames: database.TextArray[string]{"login_name1", "login_name2"}, - PreferredLoginName: "login_name1", - Machine: &Machine{ - Name: "name", - Description: "description", - EncodedSecret: "", - AccessTokenType: domain.OIDCTokenTypeBearer, - }, - }, - }, - { - name: "prepareUserQuery machine with secret found", - prepare: prepareUserQuery, - want: want{ - sqlExpectations: mockQuery( - regexp.QuoteMeta(userQuery), - userCols, - []driver.Value{ - "id", - testNow, - testNow, - "resource_owner", - uint64(20211108), - domain.UserStateActive, - domain.UserTypeMachine, - "username", - database.TextArray[string]{"login_name1", "login_name2"}, - "login_name1", - // human - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - nil, - // machine - "id", - "name", - "description", - "secret", - domain.OIDCTokenTypeBearer, - 1, - }, - ), - }, - object: &User{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - ResourceOwner: "resource_owner", - Sequence: 20211108, - State: domain.UserStateActive, - Type: domain.UserTypeMachine, - Username: "username", - LoginNames: database.TextArray[string]{"login_name1", "login_name2"}, - PreferredLoginName: "login_name1", - Machine: &Machine{ - Name: "name", - Description: "description", - EncodedSecret: "secret", - AccessTokenType: domain.OIDCTokenTypeBearer, - }, - }, - }, - { - name: "prepareUserQuery sql err", - prepare: prepareUserQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(userQuery), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*User)(nil), - }, { name: "prepareProfileQuery no result", prepare: prepareProfileQuery, diff --git a/load-test/src/org.ts b/load-test/src/org.ts index f5655432a5..1ed6778d9c 100644 --- a/load-test/src/org.ts +++ b/load-test/src/org.ts @@ -13,7 +13,7 @@ export function createOrg(accessToken: string): Promise { return new Promise((resolve, reject) => { let response = http.asyncRequest( 'POST', - url('/v2beta/organizations'), + url('/v2/organizations'), JSON.stringify({ name: `load-test-${new Date(Date.now()).toISOString()}`, }), diff --git a/load-test/src/use_cases/manipulate_user.ts b/load-test/src/use_cases/manipulate_user.ts index 2ea53bd324..104d81678e 100644 --- a/load-test/src/use_cases/manipulate_user.ts +++ b/load-test/src/use_cases/manipulate_user.ts @@ -15,7 +15,7 @@ export async function setup() { } export default async function (data: any) { - const human = await createHuman(`vu-${__VU}`, data.org, data.tokens.accessToken); + const human = await createHuman(`vu-${__VU}-${new Date(Date.now()).getTime()}`, data.org, data.tokens.accessToken); const updateRes = await updateHuman( { profile: { diff --git a/load-test/src/user.ts b/load-test/src/user.ts index 86ce71fd9b..83a6bba839 100644 --- a/load-test/src/user.ts +++ b/load-test/src/user.ts @@ -30,7 +30,7 @@ export function createHuman(username: string, org: Org, accessToken: string): Pr familyName: 'Zitizen', }, email: { - email: `zitizen-@caos.ch`, + email: `${username}@zitadel.com`, isVerified: true, }, password: { @@ -50,11 +50,11 @@ export function createHuman(username: string, org: Org, accessToken: string): Pr response .then((res) => { check(res, { - 'create user is status ok': (r) => r.status === 201, + 'create user is status ok': (r) => r.status === 200, }) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`); createHumanTrend.add(res.timings.duration); - const user = http.get(url(`/v2beta/users/${res.json('userId')!}`), { + const user = http.get(url(`/v2/users/${res.json('userId')!}`), { headers: { authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', From c1cda9bfac63ea2a34a7fda555781ec8ba5583bb Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 10 Jun 2025 09:48:46 +0200 Subject: [PATCH 094/123] fix: metadata decoding and encoding #9816 (#10024) # Which Problems Are Solved Metadata encoding and decoding on the organization detail page was broken due to use of the old, generated gRPC client. # How the Problems Are Solved The metadata values are now correctly base64 decoded and encoded on the organization detail page. # Additional Changes Refactored parts of the code to remove the dependency on the buffer npm package, replacing it with the browser-native TextEncoder and TextDecoder APIs. # Additional Context - Closes [#9816](https://github.com/zitadel/zitadel/issues/9816) --- .../metadata-dialog/metadata-dialog.component.ts | 4 ++-- .../modules/metadata/metadata/metadata.component.ts | 12 ++++++------ .../pages/orgs/org-detail/org-detail.component.ts | 8 ++++---- .../user-detail/user-detail/user-detail.component.ts | 3 +-- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/console/src/app/modules/metadata/metadata-dialog/metadata-dialog.component.ts b/console/src/app/modules/metadata/metadata-dialog/metadata-dialog.component.ts index 8deff09eee..c75e15bf04 100644 --- a/console/src/app/modules/metadata/metadata-dialog/metadata-dialog.component.ts +++ b/console/src/app/modules/metadata/metadata-dialog/metadata-dialog.component.ts @@ -4,7 +4,6 @@ import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; import { ToastService } from 'src/app/services/toast.service'; import { Metadata as MetadataV2 } from '@zitadel/proto/zitadel/metadata_pb'; import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb'; -import { Buffer } from 'buffer'; export type MetadataDialogData = { metadata: (Metadata.AsObject | MetadataV2)[]; @@ -26,9 +25,10 @@ export class MetadataDialogComponent { public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: MetadataDialogData, ) { + const decoder = new TextDecoder(); this.metadata = data.metadata.map(({ key, value }) => ({ key, - value: typeof value === 'string' ? value : Buffer.from(value as unknown as string, 'base64').toString('utf8'), + value: typeof value === 'string' ? value : decoder.decode(value), })); } diff --git a/console/src/app/modules/metadata/metadata/metadata.component.ts b/console/src/app/modules/metadata/metadata/metadata.component.ts index 7f72297c00..bdb2c7734c 100644 --- a/console/src/app/modules/metadata/metadata/metadata.component.ts +++ b/console/src/app/modules/metadata/metadata/metadata.component.ts @@ -5,7 +5,6 @@ import { Observable, ReplaySubject } from 'rxjs'; import { Metadata as MetadataV2 } from '@zitadel/proto/zitadel/metadata_pb'; import { map, startWith } from 'rxjs/operators'; import { Metadata } from 'src/app/proto/generated/zitadel/metadata_pb'; -import { Buffer } from 'buffer'; type StringMetadata = { key: string; @@ -37,12 +36,13 @@ export class MetadataComponent implements OnInit { ngOnInit() { this.dataSource$ = this.metadata$.pipe( - map((metadata) => - metadata.map(({ key, value }) => ({ + map((metadata) => { + const decoder = new TextDecoder(); + return metadata.map(({ key, value }) => ({ key, - value: Buffer.from(value as any as string, 'base64').toString('utf-8'), - })), - ), + value: typeof value === 'string' ? value : decoder.decode(value), + })); + }), startWith([] as StringMetadata[]), map((metadata) => new MatTableDataSource(metadata)), ); diff --git a/console/src/app/pages/orgs/org-detail/org-detail.component.ts b/console/src/app/pages/orgs/org-detail/org-detail.component.ts index 0de6696ac3..39514d33d3 100644 --- a/console/src/app/pages/orgs/org-detail/org-detail.component.ts +++ b/console/src/app/pages/orgs/org-detail/org-detail.component.ts @@ -1,7 +1,6 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { Router } from '@angular/router'; -import { Buffer } from 'buffer'; import { BehaviorSubject, from, Observable, of, Subject, takeUntil } from 'rxjs'; import { catchError, finalize, map } from 'rxjs/operators'; import { CreationType, MemberCreateDialogComponent } from 'src/app/modules/add-member-dialog/member-create-dialog.component'; @@ -266,10 +265,11 @@ export class OrgDetailComponent implements OnInit, OnDestroy { .listOrgMetadata() .then((resp) => { this.loadingMetadata = false; - this.metadata = resp.resultList.map((md) => { + const decoder = new TextDecoder(); + this.metadata = resp.resultList.map(({ key, value }) => { return { - key: md.key, - value: Buffer.from(md.value as string, 'base64').toString('utf-8'), + key, + value: atob(typeof value === 'string' ? value : decoder.decode(value)), }; }); }) diff --git a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts index 9370471ea4..90de097f35 100644 --- a/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts +++ b/console/src/app/pages/users/user-detail/user-detail/user-detail.component.ts @@ -4,7 +4,6 @@ import { Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog'; import { ActivatedRoute, Params, Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; -import { Buffer } from 'buffer'; import { catchError, filter, map, startWith, take } from 'rxjs/operators'; import { ChangeType } from 'src/app/modules/changes/changes.component'; import { phoneValidator, requiredValidator } from 'src/app/modules/form-field/validators/validators'; @@ -582,7 +581,7 @@ export class UserDetailComponent implements OnInit { const setFcn = (key: string, value: string) => this.newMgmtService.setUserMetadata({ key, - value: Buffer.from(value), + value: new TextEncoder().encode(value), id: user.userId, }); const removeFcn = (key: string): Promise => this.newMgmtService.removeUserMetadata({ key, id: user.userId }); From 0ae3f2a6eab7dfe60f686ed08fca7c4f1c9822e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Wed, 11 Jun 2025 11:23:39 +0200 Subject: [PATCH 095/123] docs: remove token exchange from "GA" list as we have some open issues (#10052) # Which Problems Are Solved Token Exchange will not move from Beta to GA feature, as there are still some unsolved issues # How the Problems Are Solved Remove from roadmap --- docs/docs/product/roadmap.mdx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/docs/docs/product/roadmap.mdx b/docs/docs/product/roadmap.mdx index b83efedb10..b61323fa90 100644 --- a/docs/docs/product/roadmap.mdx +++ b/docs/docs/product/roadmap.mdx @@ -394,15 +394,6 @@ Excitingly, v3 introduces the foundational elements for Actions V2, opening up a - [Manage Users Guide](https://zitadel.com/docs/guides/manage/user/scim2) - -

- Token Exchange (Impersonation) - - The Token Exchange grant implements [RFC 8693, OAuth 2.0 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) and can be used to exchange tokens to a different scope, audience or subject. - Changing the subject of an authenticated token is called impersonation or delegation. - Read more in our [Impersonation and delegation using Token Exchange](https://zitadel.com/docs/guides/integrate/token-exchange) Guide -
-
Caches From 77f0a10c1e303ac881f3c1357a73596c22ffaf16 Mon Sep 17 00:00:00 2001 From: Iraq <66622793+kkrime@users.noreply.github.com> Date: Wed, 11 Jun 2025 13:50:31 +0200 Subject: [PATCH 096/123] fix(import/export): fix for deactivated user/organization being imported as active (#9992) --- internal/api/grpc/admin/export.go | 12 +- internal/api/grpc/admin/import.go | 15 +- internal/api/grpc/management/user.go | 5 +- internal/api/grpc/user/v2/machine.go | 1 + internal/command/org.go | 13 +- internal/command/user_human.go | 65 +++-- internal/command/user_human_test.go | 367 +++++++++++++++++++++++++- internal/command/user_machine.go | 25 +- internal/command/user_machine_test.go | 109 +++++++- proto/zitadel/admin.proto | 1 + proto/zitadel/v1.proto | 2 + 11 files changed, 574 insertions(+), 41 deletions(-) diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index 2558e5b5fc..b5d36272d4 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -8,7 +8,9 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" authn_grpc "github.com/zitadel/zitadel/internal/api/grpc/authn" + "github.com/zitadel/zitadel/internal/api/grpc/org" text_grpc "github.com/zitadel/zitadel/internal/api/grpc/text" + user_converter "github.com/zitadel/zitadel/internal/api/grpc/user" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -65,7 +67,7 @@ func (s *Server) ExportData(ctx context.Context, req *admin_pb.ExportDataRequest /****************************************************************************************************************** Organization ******************************************************************************************************************/ - org := &admin_pb.DataOrg{OrgId: queriedOrg.ID, Org: &management_pb.AddOrgRequest{Name: queriedOrg.Name}} + org := &admin_pb.DataOrg{OrgId: queriedOrg.ID, OrgState: org.OrgStateToPb(queriedOrg.State), Org: &management_pb.AddOrgRequest{Name: queriedOrg.Name}} orgs[i] = org } @@ -567,6 +569,7 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w case domain.UserTypeHuman: dataUser := &v1_pb.DataHumanUser{ UserId: user.ID, + State: user_converter.UserStateToPb(user.State), User: &management_pb.ImportHumanUserRequest{ UserName: user.Username, Profile: &management_pb.ImportHumanUserRequest_Profile{ @@ -620,6 +623,7 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w case domain.UserTypeMachine: machineUsers = append(machineUsers, &v1_pb.DataMachineUser{ UserId: user.ID, + State: user_converter.UserStateToPb(user.State), User: &management_pb.AddMachineUserRequest{ UserName: user.Username, Name: user.Machine.Name, @@ -647,7 +651,6 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w ExpirationDate: timestamppb.New(key.Expiration), PublicKey: key.PublicKey, }) - } } @@ -888,7 +891,6 @@ func (s *Server) getNecessaryProjectGrantMembersForOrg(ctx context.Context, org break } } - } } } @@ -940,7 +942,6 @@ func (s *Server) getNecessaryOrgMembersForOrg(ctx context.Context, org string, p } func (s *Server) getNecessaryProjectGrantsForOrg(ctx context.Context, org string, processedOrgs []string, processedProjects []string) ([]*v1_pb.DataProjectGrant, error) { - projectGrantSearchOrg, err := query.NewProjectGrantResourceOwnerSearchQuery(org) if err != nil { return nil, err @@ -991,7 +992,7 @@ func (s *Server) getNecessaryUserGrantsForOrg(ctx context.Context, org string, p for _, userGrant := range queriedUserGrants.UserGrants { for _, projectID := range processedProjects { if projectID == userGrant.ProjectID { - //if usergrant is on a granted project + // if usergrant is on a granted project if userGrant.GrantID != "" { for _, grantID := range processedGrants { if grantID == userGrant.GrantID { @@ -1024,6 +1025,7 @@ func (s *Server) getNecessaryUserGrantsForOrg(ctx context.Context, org string, p } return userGrants, nil } + func (s *Server) getCustomLoginTexts(ctx context.Context, org string, languages []string) ([]*management_pb.SetCustomLoginTextsRequest, error) { customTexts := make([]*management_pb.SetCustomLoginTextsRequest, 0, len(languages)) for _, lang := range languages { diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 41a1e39081..84b0215f03 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -22,6 +22,7 @@ import ( action_grpc "github.com/zitadel/zitadel/internal/api/grpc/action" "github.com/zitadel/zitadel/internal/api/grpc/authn" "github.com/zitadel/zitadel/internal/api/grpc/management" + org_converter "github.com/zitadel/zitadel/internal/api/grpc/org" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" @@ -305,7 +306,8 @@ func importOrg1(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataEr ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - _, err = s.command.AddOrgWithID(ctx, org.GetOrg().GetName(), ctxData.UserID, ctxData.ResourceOwner, org.GetOrgId(), []string{}) + setOrgInactive := org_converter.OrgStateToDomain(org.OrgState) == domain.OrgStateInactive + _, err = s.command.AddOrgWithID(ctx, org.GetOrg().GetName(), ctxData.UserID, ctxData.ResourceOwner, org.GetOrgId(), setOrgInactive, []string{}) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "org", Id: org.GetOrgId(), Message: err.Error()}) if _, err := s.query.OrgByID(ctx, true, org.OrgId); err != nil { @@ -474,7 +476,10 @@ func importHumanUsers(ctx context.Context, s *Server, errors *[]*admin_pb.Import logging.Debugf("import user: %s", user.GetUserId()) human, passwordless, links := management.ImportHumanUserRequestToDomain(user.User) human.AggregateID = user.UserId - _, _, err := s.command.ImportHuman(ctx, org.GetOrgId(), human, passwordless, links, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessInitCode) + userState := user.State.ToDomain() + + //nolint:staticcheck + _, _, err := s.command.ImportHuman(ctx, org.GetOrgId(), human, passwordless, &userState, links, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessInitCode) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "human_user", Id: user.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -510,7 +515,8 @@ func importMachineUsers(ctx context.Context, s *Server, errors *[]*admin_pb.Impo } for _, user := range org.GetMachineUsers() { logging.Debugf("import user: %s", user.GetUserId()) - _, err := s.command.AddMachine(ctx, management.AddMachineUserRequestToCommand(user.GetUser(), org.GetOrgId()), nil) + userState := user.State.ToDomain() + _, err := s.command.AddMachine(ctx, management.AddMachineUserRequestToCommand(user.GetUser(), org.GetOrgId()), &userState, nil) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "machine_user", Id: user.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -609,7 +615,6 @@ func importUserLinks(ctx context.Context, s *Server, errors *[]*admin_pb.ImportD successOrg.UserLinks = append(successOrg.UserLinks, &admin_pb.ImportDataSuccessUserLinks{UserId: userLinks.GetUserId(), IdpId: userLinks.GetIdpId(), ExternalUserId: userLinks.GetProvidedUserId(), DisplayName: userLinks.GetProvidedUserName()}) } return nil - } func importProjects(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataError, successOrg *admin_pb.ImportDataSuccessOrg, org *admin_pb.DataOrg, count *counts) (err error) { @@ -750,6 +755,7 @@ func importActions(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDat } return nil } + func importProjectRoles(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataError, successOrg *admin_pb.ImportDataSuccessOrg, org *admin_pb.DataOrg, count *counts) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -805,6 +811,7 @@ func importResources(ctx context.Context, s *Server, errors *[]*admin_pb.ImportD importDomainClaimedMessageTexts(ctx, s, errors, org) importPasswordlessRegistrationMessageTexts(ctx, s, errors, org) importInviteUserMessageTexts(ctx, s, errors, org) + if err := importHumanUsers(ctx, s, errors, successOrg, org, count, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessInitCode); err != nil { return err } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index ae1040cd1e..09b9faa756 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -273,7 +273,8 @@ func (s *Server) ImportHumanUser(ctx context.Context, req *mgmt_pb.ImportHumanUs if err != nil { return nil, err } - addedHuman, code, err := s.command.ImportHuman(ctx, authz.GetCtxData(ctx).OrgID, human, passwordless, links, initCodeGenerator, phoneCodeGenerator, emailCodeGenerator, passwordlessInitCode) + //nolint:staticcheck + addedHuman, code, err := s.command.ImportHuman(ctx, authz.GetCtxData(ctx).OrgID, human, passwordless, nil, links, initCodeGenerator, phoneCodeGenerator, emailCodeGenerator, passwordlessInitCode) if err != nil { return nil, err } @@ -297,7 +298,7 @@ func (s *Server) ImportHumanUser(ctx context.Context, req *mgmt_pb.ImportHumanUs func (s *Server) AddMachineUser(ctx context.Context, req *mgmt_pb.AddMachineUserRequest) (*mgmt_pb.AddMachineUserResponse, error) { machine := AddMachineUserRequestToCommand(req, authz.GetCtxData(ctx).OrgID) - objectDetails, err := s.command.AddMachine(ctx, machine, nil) + objectDetails, err := s.command.AddMachine(ctx, machine, nil, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2/machine.go b/internal/api/grpc/user/v2/machine.go index 010ba75678..ad02b2289e 100644 --- a/internal/api/grpc/user/v2/machine.go +++ b/internal/api/grpc/user/v2/machine.go @@ -25,6 +25,7 @@ func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.Crea details, err := s.command.AddMachine( ctx, cmd, + nil, s.command.NewPermissionCheckUserWrite(ctx), command.AddMachineWithUsernameToIDFallback(), ) diff --git a/internal/command/org.go b/internal/command/org.go index ddf99797e3..faab882d68 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -317,7 +317,7 @@ func (c *Commands) checkOrgExists(ctx context.Context, orgID string) error { return nil } -func (c *Commands) AddOrgWithID(ctx context.Context, name, userID, resourceOwner, orgID string, claimedUserIDs []string) (_ *domain.Org, err error) { +func (c *Commands) AddOrgWithID(ctx context.Context, name, userID, resourceOwner, orgID string, setOrgInactive bool, claimedUserIDs []string) (_ *domain.Org, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -329,7 +329,7 @@ func (c *Commands) AddOrgWithID(ctx context.Context, name, userID, resourceOwner return nil, zerrors.ThrowNotFound(nil, "ORG-lapo2m", "Errors.Org.AlreadyExisting") } - return c.addOrgWithIDAndMember(ctx, name, userID, resourceOwner, orgID, claimedUserIDs) + return c.addOrgWithIDAndMember(ctx, name, userID, resourceOwner, orgID, setOrgInactive, claimedUserIDs) } func (c *Commands) AddOrg(ctx context.Context, name, userID, resourceOwner string, claimedUserIDs []string) (*domain.Org, error) { @@ -342,10 +342,10 @@ func (c *Commands) AddOrg(ctx context.Context, name, userID, resourceOwner strin return nil, zerrors.ThrowInternal(err, "COMMA-OwciI", "Errors.Internal") } - return c.addOrgWithIDAndMember(ctx, name, userID, resourceOwner, orgID, claimedUserIDs) + return c.addOrgWithIDAndMember(ctx, name, userID, resourceOwner, orgID, false, claimedUserIDs) } -func (c *Commands) addOrgWithIDAndMember(ctx context.Context, name, userID, resourceOwner, orgID string, claimedUserIDs []string) (_ *domain.Org, err error) { +func (c *Commands) addOrgWithIDAndMember(ctx context.Context, name, userID, resourceOwner, orgID string, setOrgInactive bool, claimedUserIDs []string) (_ *domain.Org, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -363,10 +363,15 @@ func (c *Commands) addOrgWithIDAndMember(ctx context.Context, name, userID, reso return nil, err } events = append(events, orgMemberEvent) + if setOrgInactive { + deactivateOrgEvent := org.NewOrgDeactivatedEvent(ctx, orgAgg) + events = append(events, deactivateOrgEvent) + } pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { return nil, err } + err = AppendAndReduce(addedOrg, pushedEvents...) if err != nil { return nil, err diff --git a/internal/command/user_human.go b/internal/command/user_human.go index 9e6ba43629..07628b9e19 100644 --- a/internal/command/user_human.go +++ b/internal/command/user_human.go @@ -428,7 +428,7 @@ func (h *AddHuman) shouldAddInitCode() bool { } // Deprecated: use commands.AddUserHuman -func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (_ *domain.Human, passwordlessCode *domain.PasswordlessInitCode, err error) { +func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, state *domain.UserState, links []*domain.UserIDPLink, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (_ *domain.Human, passwordlessCode *domain.PasswordlessInitCode, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -455,10 +455,32 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. } } - events, addedHuman, addedCode, code, err := c.importHuman(ctx, orgID, human, passwordless, links, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator) + events, userAgg, addedHuman, addedCode, code, err := c.importHuman(ctx, orgID, human, passwordless, links, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator) if err != nil { return nil, nil, err } + if state != nil { + var event eventstore.Command + switch *state { + case domain.UserStateInactive: + event = user.NewUserDeactivatedEvent(ctx, userAgg) + case domain.UserStateLocked: + event = user.NewUserLockedEvent(ctx, userAgg) + case domain.UserStateDeleted: + // users are never imported if deleted + case domain.UserStateActive: + // added because of the linter + case domain.UserStateSuspend: + // added because of the linter + case domain.UserStateInitial: + // added because of the linter + case domain.UserStateUnspecified: + // added because of the linter + } + if event != nil { + events = append(events, event) + } + } pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { return nil, nil, err @@ -479,48 +501,48 @@ func (c *Commands) ImportHuman(ctx context.Context, orgID string, human *domain. return writeModelToHuman(addedHuman), passwordlessCode, nil } -func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (events []eventstore.Command, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) { +func (c *Commands) importHuman(ctx context.Context, orgID string, human *domain.Human, passwordless bool, links []*domain.UserIDPLink, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator, passwordlessCodeGenerator crypto.Generator) (events []eventstore.Command, userAgg *eventstore.Aggregate, humanWriteModel *HumanWriteModel, passwordlessCodeWriteModel *HumanPasswordlessInitCodeWriteModel, code string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() if orgID == "" { - return nil, nil, nil, "", zerrors.ThrowInvalidArgument(nil, "COMMAND-00p2b", "Errors.Org.Empty") + return nil, nil, nil, nil, "", zerrors.ThrowInvalidArgument(nil, "COMMAND-00p2b", "Errors.Org.Empty") } if err = human.Normalize(); err != nil { - return nil, nil, nil, "", err + return nil, nil, nil, nil, "", err } - events, humanWriteModel, err = c.createHuman(ctx, orgID, human, links, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) + events, userAgg, humanWriteModel, err = c.createHuman(ctx, orgID, human, links, passwordless, domainPolicy, pwPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator) if err != nil { - return nil, nil, nil, "", err + return nil, nil, nil, nil, "", err } if passwordless { var codeEvent eventstore.Command codeEvent, passwordlessCodeWriteModel, code, err = c.humanAddPasswordlessInitCode(ctx, human.AggregateID, orgID, true, passwordlessCodeGenerator) if err != nil { - return nil, nil, nil, "", err + return nil, nil, nil, nil, "", err } events = append(events, codeEvent) } - return events, humanWriteModel, passwordlessCodeWriteModel, code, nil + return events, userAgg, humanWriteModel, passwordlessCodeWriteModel, code, nil } -func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, links []*domain.UserIDPLink, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, addedHuman *HumanWriteModel, err error) { +func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain.Human, links []*domain.UserIDPLink, passwordless bool, domainPolicy *domain.DomainPolicy, pwPolicy *domain.PasswordComplexityPolicy, initCodeGenerator, emailCodeGenerator, phoneCodeGenerator crypto.Generator) (events []eventstore.Command, userAgg *eventstore.Aggregate, addedHuman *HumanWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() if err = human.CheckDomainPolicy(domainPolicy); err != nil { - return nil, nil, err + return nil, nil, nil, err } human.Username = strings.TrimSpace(human.Username) human.EmailAddress = human.EmailAddress.Normalize() if err = c.userValidateDomain(ctx, orgID, human.Username, domainPolicy.UserLoginMustBeDomain); err != nil { - return nil, nil, err + return nil, nil, nil, err } if human.AggregateID == "" { userID, err := c.idGenerator.Next() if err != nil { - return nil, nil, err + return nil, nil, nil, err } human.AggregateID = userID } @@ -528,20 +550,21 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. human.EnsureDisplayName() if human.Password != nil { if err := human.HashPasswordIfExisting(ctx, pwPolicy, c.userPasswordHasher, human.Password.ChangeRequired); err != nil { - return nil, nil, err + return nil, nil, nil, err } } addedHuman = NewHumanWriteModel(human.AggregateID, orgID) - //TODO: adlerhurst maybe we could simplify the code below - userAgg := UserAggregateFromWriteModel(&addedHuman.WriteModel) + + // TODO: adlerhurst maybe we could simplify the code below + userAgg = UserAggregateFromWriteModelCtx(ctx, &addedHuman.WriteModel) events = append(events, createAddHumanEvent(ctx, userAgg, human, domainPolicy.UserLoginMustBeDomain)) for _, link := range links { event, err := c.addUserIDPLink(ctx, userAgg, link, false) if err != nil { - return nil, nil, err + return nil, nil, nil, err } events = append(events, event) } @@ -549,7 +572,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. if human.IsInitialState(passwordless, len(links) > 0) { initCode, err := domain.NewInitUserCode(initCodeGenerator) if err != nil { - return nil, nil, err + return nil, nil, nil, err } events = append(events, user.NewHumanInitialCodeAddedEvent(ctx, userAgg, initCode.Code, initCode.Expiry, "")) } else { @@ -558,7 +581,7 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. } else { emailCode, _, err := domain.NewEmailCode(emailCodeGenerator) if err != nil { - return nil, nil, err + return nil, nil, nil, err } events = append(events, user.NewHumanEmailCodeAddedEvent(ctx, userAgg, emailCode.Code, emailCode.Expiry, "")) } @@ -567,14 +590,14 @@ func (c *Commands) createHuman(ctx context.Context, orgID string, human *domain. if human.Phone != nil && human.PhoneNumber != "" && !human.IsPhoneVerified { phoneCode, generatorID, err := c.newPhoneCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypeVerifyPhoneCode, c.userEncryption, c.defaultSecretGenerators.PhoneVerificationCode) //nolint:staticcheck if err != nil { - return nil, nil, err + return nil, nil, nil, err } events = append(events, user.NewHumanPhoneCodeAddedEvent(ctx, userAgg, phoneCode.CryptedCode(), phoneCode.CodeExpiry(), generatorID)) } else if human.Phone != nil && human.PhoneNumber != "" && human.IsPhoneVerified { events = append(events, user.NewHumanPhoneVerifiedEvent(ctx, userAgg)) } - return events, addedHuman, nil + return events, userAgg, addedHuman, nil } func (c *Commands) HumanSkipMFAInit(ctx context.Context, userID, resourceowner string) (err error) { diff --git a/internal/command/user_human_test.go b/internal/command/user_human_test.go index 78d7248516..1ef3e2aab6 100644 --- a/internal/command/user_human_test.go +++ b/internal/command/user_human_test.go @@ -1200,7 +1200,8 @@ func TestCommandSide_AddHuman(t *testing.T) { }, wantID: "user1", }, - }, { + }, + { name: "add human (with return code), ok", fields: fields{ eventstore: expectEventstore( @@ -1432,6 +1433,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { orgID string human *domain.Human passwordless bool + state *domain.UserState links []*domain.UserIDPLink secretGenerator crypto.Generator passwordlessInitCode crypto.Generator @@ -1584,7 +1586,8 @@ func TestCommandSide_ImportHuman(t *testing.T) { res: res{ err: zerrors.IsErrorInvalidArgument, }, - }, { + }, + { name: "add human (with password and initial code), ok", given: func(t *testing.T) (fields, args) { return fields{ @@ -2985,6 +2988,364 @@ func TestCommandSide_ImportHuman(t *testing.T) { }, }, }, + { + name: "add human (with idp, auto creation not allowed) + deactivated state, ok", + given: func(t *testing.T) (fields, args) { + return fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewIDPConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + "name", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeUnspecified, + false, + ), + ), + eventFromEventPusher( + org.NewIDPOIDCConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "clientID", + "idpID", + "issuer", + "authEndpoint", + "tokenEndpoint", + nil, + domain.OIDCMappingFieldUnspecified, + domain.OIDCMappingFieldUnspecified, + ), + ), + eventFromEventPusher( + func() eventstore.Command { + e, _ := org.NewOIDCIDPChangedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "config1", + []idp.OIDCIDPChanges{ + idp.ChangeOIDCOptions(idp.OptionChanges{IsCreationAllowed: gu.Ptr(false)}), + }, + ) + return e + }(), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewIDPConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + "name", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeUnspecified, + false, + ), + ), + eventFromEventPusher( + org.NewIDPOIDCConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "clientID", + "idpID", + "issuer", + "authEndpoint", + "tokenEndpoint", + nil, + domain.OIDCMappingFieldUnspecified, + domain.OIDCMappingFieldUnspecified, + ), + ), + eventFromEventPusher( + func() eventstore.Command { + e, _ := org.NewOIDCIDPChangedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "config1", + []idp.OIDCIDPChanges{ + idp.ChangeOIDCOptions(idp.OptionChanges{ + IsCreationAllowed: gu.Ptr(true), + IsAutoCreation: gu.Ptr(false), + }), + }, + ) + return e + }(), + ), + eventFromEventPusher( + org.NewIdentityProviderAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + domain.IdentityProviderTypeOrg, + ), + ), + ), + expectPush( + newAddHumanEvent("", false, true, "", AllowedLanguage), + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "idpID", + "name", + "externalID", + ), + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args{ + ctx: context.Background(), + orgID: "org1", + state: func() *domain.UserState { + state := domain.UserStateInactive + return &state + }(), + human: &domain.Human{ + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + PreferredLanguage: AllowedLanguage, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + }, + links: []*domain.UserIDPLink{ + { + IDPConfigID: "idpID", + ExternalUserID: "externalID", + DisplayName: "name", + }, + }, + secretGenerator: GetMockSecretGenerator(t), + } + }, + res: res{ + wantHuman: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: AllowedLanguage, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + State: domain.UserStateInactive, + }, + }, + }, + { + name: "add human (with idp, auto creation not allowed) + locked state, ok", + given: func(t *testing.T) (fields, args) { + return fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewPasswordComplexityPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + 1, + false, + false, + false, + false, + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewIDPConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + "name", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeUnspecified, + false, + ), + ), + eventFromEventPusher( + org.NewIDPOIDCConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "clientID", + "idpID", + "issuer", + "authEndpoint", + "tokenEndpoint", + nil, + domain.OIDCMappingFieldUnspecified, + domain.OIDCMappingFieldUnspecified, + ), + ), + eventFromEventPusher( + func() eventstore.Command { + e, _ := org.NewOIDCIDPChangedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "config1", + []idp.OIDCIDPChanges{ + idp.ChangeOIDCOptions(idp.OptionChanges{IsCreationAllowed: gu.Ptr(false)}), + }, + ) + return e + }(), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewIDPConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + "name", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeUnspecified, + false, + ), + ), + eventFromEventPusher( + org.NewIDPOIDCConfigAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "clientID", + "idpID", + "issuer", + "authEndpoint", + "tokenEndpoint", + nil, + domain.OIDCMappingFieldUnspecified, + domain.OIDCMappingFieldUnspecified, + ), + ), + eventFromEventPusher( + func() eventstore.Command { + e, _ := org.NewOIDCIDPChangedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "config1", + []idp.OIDCIDPChanges{ + idp.ChangeOIDCOptions(idp.OptionChanges{ + IsCreationAllowed: gu.Ptr(true), + IsAutoCreation: gu.Ptr(false), + }), + }, + ) + return e + }(), + ), + eventFromEventPusher( + org.NewIdentityProviderAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "idpID", + domain.IdentityProviderTypeOrg, + ), + ), + ), + expectPush( + newAddHumanEvent("", false, true, "", AllowedLanguage), + user.NewUserIDPLinkAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "idpID", + "name", + "externalID", + ), + user.NewHumanEmailVerifiedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate), + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + userPasswordHasher: mockPasswordHasher("x"), + }, + args{ + ctx: context.Background(), + orgID: "org1", + state: func() *domain.UserState { + state := domain.UserStateLocked + return &state + }(), + human: &domain.Human{ + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + PreferredLanguage: AllowedLanguage, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + }, + links: []*domain.UserIDPLink{ + { + IDPConfigID: "idpID", + ExternalUserID: "externalID", + DisplayName: "name", + }, + }, + secretGenerator: GetMockSecretGenerator(t), + } + }, + res: res{ + wantHuman: &domain.Human{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + Username: "username", + Profile: &domain.Profile{ + FirstName: "firstname", + LastName: "lastname", + DisplayName: "firstname lastname", + PreferredLanguage: AllowedLanguage, + }, + Email: &domain.Email{ + EmailAddress: "email@test.ch", + IsEmailVerified: true, + }, + State: domain.UserStateLocked, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2996,7 +3357,7 @@ func TestCommandSide_ImportHuman(t *testing.T) { newEncryptedCodeWithDefault: f.newEncryptedCodeWithDefault, defaultSecretGenerators: f.defaultSecretGenerators, } - gotHuman, gotCode, err := r.ImportHuman(a.ctx, a.orgID, a.human, a.passwordless, a.links, a.secretGenerator, a.secretGenerator, a.secretGenerator, a.secretGenerator) + gotHuman, gotCode, err := r.ImportHuman(a.ctx, a.orgID, a.human, a.passwordless, a.state, a.links, a.secretGenerator, a.secretGenerator, a.secretGenerator, a.secretGenerator) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user_machine.go b/internal/command/user_machine.go index 7c8fd89eac..75ed43ee69 100644 --- a/internal/command/user_machine.go +++ b/internal/command/user_machine.go @@ -79,7 +79,7 @@ func AddMachineWithUsernameToIDFallback() addMachineOption { } } -func (c *Commands) AddMachine(ctx context.Context, machine *Machine, check PermissionCheck, options ...addMachineOption) (_ *domain.ObjectDetails, err error) { +func (c *Commands) AddMachine(ctx context.Context, machine *Machine, state *domain.UserState, check PermissionCheck, options ...addMachineOption) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -107,6 +107,29 @@ func (c *Commands) AddMachine(ctx context.Context, machine *Machine, check Permi return nil, err } + if state != nil { + var cmd eventstore.Command + switch *state { + case domain.UserStateInactive: + cmd = user.NewUserDeactivatedEvent(ctx, &agg.Aggregate) + case domain.UserStateLocked: + cmd = user.NewUserLockedEvent(ctx, &agg.Aggregate) + case domain.UserStateDeleted: + // users are never imported if deleted + case domain.UserStateActive: + // added because of the linter + case domain.UserStateSuspend: + // added because of the linter + case domain.UserStateInitial: + // added because of the linter + case domain.UserStateUnspecified: + // added because of the linter + } + if cmd != nil { + cmds = append(cmds, cmd) + } + } + events, err := c.eventstore.Push(ctx, cmds...) if err != nil { return nil, err diff --git a/internal/command/user_machine_test.go b/internal/command/user_machine_test.go index 19548ae9c6..6d94154a42 100644 --- a/internal/command/user_machine_test.go +++ b/internal/command/user_machine_test.go @@ -24,6 +24,7 @@ func TestCommandSide_AddMachine(t *testing.T) { type args struct { ctx context.Context machine *Machine + state *domain.UserState check PermissionCheck options func(*Commands) []addMachineOption } @@ -419,6 +420,112 @@ func TestCommandSide_AddMachine(t *testing.T) { err: zerrors.IsPermissionDenied, }, }, + { + name: "add machine, ok + deactive state", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + user.NewUserDeactivatedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + }, + state: func() *domain.UserState { + state := domain.UserStateInactive + return &state + }(), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "add machine, ok + locked state", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + org.NewDomainPolicyAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + true, + true, + true, + ), + ), + ), + expectPush( + user.NewMachineAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "name", + "description", + true, + domain.OIDCTokenTypeBearer, + ), + user.NewUserLockedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + }, + state: func() *domain.UserState { + state := domain.UserStateLocked + return &state + }(), + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -431,7 +538,7 @@ func TestCommandSide_AddMachine(t *testing.T) { if tt.args.options != nil { options = tt.args.options(r) } - got, err := r.AddMachine(tt.args.ctx, tt.args.machine, tt.args.check, options...) + got, err := r.AddMachine(tt.args.ctx, tt.args.machine, tt.args.state, tt.args.check, options...) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index d8c88d540b..da496b7c7d 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -9005,6 +9005,7 @@ message DataOrg { repeated zitadel.management.v1.SetCustomVerifySMSOTPMessageTextRequest verify_sms_otp_messages = 37; repeated zitadel.management.v1.SetCustomVerifyEmailOTPMessageTextRequest verify_email_otp_messages = 38; repeated zitadel.management.v1.SetCustomInviteUserMessageTextRequest invite_user_messages = 39; + zitadel.org.v1.OrgState org_state = 40; } message ImportDataResponse{ diff --git a/proto/zitadel/v1.proto b/proto/zitadel/v1.proto index c186ea7d61..beb91116f1 100644 --- a/proto/zitadel/v1.proto +++ b/proto/zitadel/v1.proto @@ -172,10 +172,12 @@ message DataOIDCApplication { message DataHumanUser { string user_id = 1; zitadel.management.v1.ImportHumanUserRequest user = 2; + zitadel.user.v1.UserState state = 3; } message DataMachineUser { string user_id = 1; zitadel.management.v1.AddMachineUserRequest user = 2; + zitadel.user.v1.UserState state = 3; } message DataAction { string action_id = 1; From 83839fc2eff533611abb2b0a81c09ae65eff94b0 Mon Sep 17 00:00:00 2001 From: Abhinav Sethi Date: Thu, 12 Jun 2025 13:03:25 -0400 Subject: [PATCH 097/123] fix: enable opentelemetry metrics for river queue (#10044) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Right now we have no visibility into river queue's job processing times and queue sizes. This makes it difficult to reliably know if notifications are actually being published in a reasonable time and current queue size. # How the Problems Are Solved Integrates River's OpenTelemetry middleware with Zitadel's metrics system by adding the otelriver middleware to the queue configuration. # Additional Changes - Updated dependencies to include required `otelriver` package # Additional Context Example output from `/debug/metrics`
output # HELP failed_deliveries_json_total Failed JSON message deliveries # TYPE failed_deliveries_json_total counter failed_deliveries_json_total{otel_scope_name="",otel_scope_version="",triggering_event_type="user.human.phone.code.added"} 2 # HELP go_gc_duration_seconds A summary of the wall-time pause (stop-the-world) duration in garbage collection cycles. # TYPE go_gc_duration_seconds summary go_gc_duration_seconds{quantile="0"} 3.8e-05 go_gc_duration_seconds{quantile="0.25"} 6.3916e-05 go_gc_duration_seconds{quantile="0.5"} 7.5584e-05 go_gc_duration_seconds{quantile="0.75"} 9.2584e-05 go_gc_duration_seconds{quantile="1"} 0.000204292 go_gc_duration_seconds_sum 0.003028502 go_gc_duration_seconds_count 34 # HELP go_gc_gogc_percent Heap size target percentage configured by the user, otherwise 100. This value is set by the GOGC environment variable, and the runtime/debug.SetGCPercent function. Sourced from /gc/gogc:percent # TYPE go_gc_gogc_percent gauge go_gc_gogc_percent 100 # HELP go_gc_gomemlimit_bytes Go runtime memory limit configured by the user, otherwise math.MaxInt64. This value is set by the GOMEMLIMIT environment variable, and the runtime/debug.SetMemoryLimit function. Sourced from /gc/gomemlimit:bytes # TYPE go_gc_gomemlimit_bytes gauge go_gc_gomemlimit_bytes 9.223372036854776e+18 # HELP go_goroutines Number of goroutines that currently exist. # TYPE go_goroutines gauge go_goroutines 231 # HELP go_info Information about the Go environment. # TYPE go_info gauge go_info{version="go1.24.3"} 1 # HELP go_memstats_alloc_bytes Number of bytes allocated in heap and currently in use. Equals to /memory/classes/heap/objects:bytes. # TYPE go_memstats_alloc_bytes gauge go_memstats_alloc_bytes 7.7565832e+07 # HELP go_memstats_alloc_bytes_total Total number of bytes allocated in heap until now, even if released already. Equals to /gc/heap/allocs:bytes. # TYPE go_memstats_alloc_bytes_total counter go_memstats_alloc_bytes_total 7.3319844e+08 # HELP go_memstats_buck_hash_sys_bytes Number of bytes used by the profiling bucket hash table. Equals to /memory/classes/profiling/buckets:bytes. # TYPE go_memstats_buck_hash_sys_bytes gauge go_memstats_buck_hash_sys_bytes 1.63816e+06 # HELP go_memstats_frees_total Total number of heap objects frees. Equals to /gc/heap/frees:objects + /gc/heap/tiny/allocs:objects. # TYPE go_memstats_frees_total counter go_memstats_frees_total 1.1496925e+07 # HELP go_memstats_gc_sys_bytes Number of bytes used for garbage collection system metadata. Equals to /memory/classes/metadata/other:bytes. # TYPE go_memstats_gc_sys_bytes gauge go_memstats_gc_sys_bytes 5.182776e+06 # HELP go_memstats_heap_alloc_bytes Number of heap bytes allocated and currently in use, same as go_memstats_alloc_bytes. Equals to /memory/classes/heap/objects:bytes. # TYPE go_memstats_heap_alloc_bytes gauge go_memstats_heap_alloc_bytes 7.7565832e+07 # HELP go_memstats_heap_idle_bytes Number of heap bytes waiting to be used. Equals to /memory/classes/heap/released:bytes + /memory/classes/heap/free:bytes. # TYPE go_memstats_heap_idle_bytes gauge go_memstats_heap_idle_bytes 5.8179584e+07 # HELP go_memstats_heap_inuse_bytes Number of heap bytes that are in use. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes # TYPE go_memstats_heap_inuse_bytes gauge go_memstats_heap_inuse_bytes 8.5868544e+07 # HELP go_memstats_heap_objects Number of currently allocated objects. Equals to /gc/heap/objects:objects. # TYPE go_memstats_heap_objects gauge go_memstats_heap_objects 573723 # HELP go_memstats_heap_released_bytes Number of heap bytes released to OS. Equals to /memory/classes/heap/released:bytes. # TYPE go_memstats_heap_released_bytes gauge go_memstats_heap_released_bytes 7.20896e+06 # HELP go_memstats_heap_sys_bytes Number of heap bytes obtained from system. Equals to /memory/classes/heap/objects:bytes + /memory/classes/heap/unused:bytes + /memory/classes/heap/released:bytes + /memory/classes/heap/free:bytes. # TYPE go_memstats_heap_sys_bytes gauge go_memstats_heap_sys_bytes 1.44048128e+08 # HELP go_memstats_last_gc_time_seconds Number of seconds since 1970 of last garbage collection. # TYPE go_memstats_last_gc_time_seconds gauge go_memstats_last_gc_time_seconds 1.749491558214289e+09 # HELP go_memstats_mallocs_total Total number of heap objects allocated, both live and gc-ed. Semantically a counter version for go_memstats_heap_objects gauge. Equals to /gc/heap/allocs:objects + /gc/heap/tiny/allocs:objects. # TYPE go_memstats_mallocs_total counter go_memstats_mallocs_total 1.2070648e+07 # HELP go_memstats_mcache_inuse_bytes Number of bytes in use by mcache structures. Equals to /memory/classes/metadata/mcache/inuse:bytes. # TYPE go_memstats_mcache_inuse_bytes gauge go_memstats_mcache_inuse_bytes 16912 # HELP go_memstats_mcache_sys_bytes Number of bytes used for mcache structures obtained from system. Equals to /memory/classes/metadata/mcache/inuse:bytes + /memory/classes/metadata/mcache/free:bytes. # TYPE go_memstats_mcache_sys_bytes gauge go_memstats_mcache_sys_bytes 31408 # HELP go_memstats_mspan_inuse_bytes Number of bytes in use by mspan structures. Equals to /memory/classes/metadata/mspan/inuse:bytes. # TYPE go_memstats_mspan_inuse_bytes gauge go_memstats_mspan_inuse_bytes 1.3496e+06 # HELP go_memstats_mspan_sys_bytes Number of bytes used for mspan structures obtained from system. Equals to /memory/classes/metadata/mspan/inuse:bytes + /memory/classes/metadata/mspan/free:bytes. # TYPE go_memstats_mspan_sys_bytes gauge go_memstats_mspan_sys_bytes 2.18688e+06 # HELP go_memstats_next_gc_bytes Number of heap bytes when next garbage collection will take place. Equals to /gc/heap/goal:bytes. # TYPE go_memstats_next_gc_bytes gauge go_memstats_next_gc_bytes 1.34730994e+08 # HELP go_memstats_other_sys_bytes Number of bytes used for other system allocations. Equals to /memory/classes/other:bytes. # TYPE go_memstats_other_sys_bytes gauge go_memstats_other_sys_bytes 3.125168e+06 # HELP go_memstats_stack_inuse_bytes Number of bytes obtained from system for stack allocator in non-CGO environments. Equals to /memory/classes/heap/stacks:bytes. # TYPE go_memstats_stack_inuse_bytes gauge go_memstats_stack_inuse_bytes 2.752512e+06 # HELP go_memstats_stack_sys_bytes Number of bytes obtained from system for stack allocator. Equals to /memory/classes/heap/stacks:bytes + /memory/classes/os-stacks:bytes. # TYPE go_memstats_stack_sys_bytes gauge go_memstats_stack_sys_bytes 2.752512e+06 # HELP go_memstats_sys_bytes Number of bytes obtained from system. Equals to /memory/classes/total:byte. # TYPE go_memstats_sys_bytes gauge go_memstats_sys_bytes 1.58965032e+08 # HELP go_sched_gomaxprocs_threads The current runtime.GOMAXPROCS setting, or the number of operating system threads that can execute user-level Go code simultaneously. Sourced from /sched/gomaxprocs:threads # TYPE go_sched_gomaxprocs_threads gauge go_sched_gomaxprocs_threads 14 # HELP go_threads Number of OS threads created. # TYPE go_threads gauge go_threads 25 # HELP grpc_server_grpc_status_code_total Grpc status code counter # TYPE grpc_server_grpc_status_code_total counter grpc_server_grpc_status_code_total{grpc_method="/zitadel.management.v1.ManagementService/ListUserChanges",otel_scope_name="",otel_scope_version="",return_code="200"} 1 grpc_server_grpc_status_code_total{grpc_method="/zitadel.management.v1.ManagementService/ListUserMetadata",otel_scope_name="",otel_scope_version="",return_code="200"} 2 grpc_server_grpc_status_code_total{grpc_method="/zitadel.management.v1.ManagementService/ResendHumanPhoneVerification",otel_scope_name="",otel_scope_version="",return_code="200"} 1 grpc_server_grpc_status_code_total{grpc_method="/zitadel.user.v2.UserService/GetUserByID",otel_scope_name="",otel_scope_version="",return_code="200"} 1 # HELP grpc_server_request_counter_total Grpc request counter # TYPE grpc_server_request_counter_total counter grpc_server_request_counter_total{grpc_method="/zitadel.management.v1.ManagementService/ListUserChanges",otel_scope_name="",otel_scope_version=""} 1 grpc_server_request_counter_total{grpc_method="/zitadel.management.v1.ManagementService/ListUserMetadata",otel_scope_name="",otel_scope_version=""} 2 grpc_server_request_counter_total{grpc_method="/zitadel.management.v1.ManagementService/ResendHumanPhoneVerification",otel_scope_name="",otel_scope_version=""} 1 grpc_server_request_counter_total{grpc_method="/zitadel.user.v2.UserService/GetUserByID",otel_scope_name="",otel_scope_version=""} 1 # HELP grpc_server_total_request_counter_total Total grpc request counter # TYPE grpc_server_total_request_counter_total counter grpc_server_total_request_counter_total{otel_scope_name="",otel_scope_version=""} 5 # HELP otel_scope_info Instrumentation Scope metadata # TYPE otel_scope_info gauge otel_scope_info{otel_scope_name="",otel_scope_version=""} 1 otel_scope_info{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version=""} 1 # HELP projection_events_processed_total Number of events reduced to process projection updates # TYPE projection_events_processed_total counter projection_events_processed_total{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",success="true"} 1 projection_events_processed_total{otel_scope_name="",otel_scope_version="",projection="projections.instance_features2",success="true"} 0 projection_events_processed_total{otel_scope_name="",otel_scope_version="",projection="projections.login_names3",success="true"} 0 projection_events_processed_total{otel_scope_name="",otel_scope_version="",projection="projections.notifications",success="true"} 1 projection_events_processed_total{otel_scope_name="",otel_scope_version="",projection="projections.orgs1",success="true"} 0 projection_events_processed_total{otel_scope_name="",otel_scope_version="",projection="projections.user_metadata5",success="true"} 0 projection_events_processed_total{otel_scope_name="",otel_scope_version="",projection="projections.users14",success="true"} 0 # HELP projection_handle_timer_seconds Time taken to process a projection update # TYPE projection_handle_timer_seconds histogram projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="0.005"} 0 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="0.01"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="0.05"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="0.1"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="1"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="5"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="10"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="30"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="60"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="120"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="+Inf"} 1 projection_handle_timer_seconds_sum{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler"} 0.007344541 projection_handle_timer_seconds_count{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="0.005"} 0 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="0.01"} 0 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="0.05"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="0.1"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="1"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="5"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="10"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="30"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="60"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="120"} 1 projection_handle_timer_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="+Inf"} 1 projection_handle_timer_seconds_sum{otel_scope_name="",otel_scope_version="",projection="projections.notifications"} 0.014258458 projection_handle_timer_seconds_count{otel_scope_name="",otel_scope_version="",projection="projections.notifications"} 1 # HELP projection_state_latency_seconds When finishing processing a batch of events, this track the age of the last events seen from current time # TYPE projection_state_latency_seconds histogram projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="0.1"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="0.5"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="1"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="5"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="10"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="30"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="60"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="300"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="600"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="1800"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler",le="+Inf"} 1 projection_state_latency_seconds_sum{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler"} 0.012979 projection_state_latency_seconds_count{otel_scope_name="",otel_scope_version="",projection="projections.execution_handler"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="0.1"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="0.5"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="1"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="5"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="10"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="30"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="60"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="300"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="600"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="1800"} 1 projection_state_latency_seconds_bucket{otel_scope_name="",otel_scope_version="",projection="projections.notifications",le="+Inf"} 1 projection_state_latency_seconds_sum{otel_scope_name="",otel_scope_version="",projection="projections.notifications"} 0.0199 projection_state_latency_seconds_count{otel_scope_name="",otel_scope_version="",projection="projections.notifications"} 1 # HELP promhttp_metric_handler_requests_in_flight Current number of scrapes being served. # TYPE promhttp_metric_handler_requests_in_flight gauge promhttp_metric_handler_requests_in_flight 1 # HELP promhttp_metric_handler_requests_total Total number of scrapes by HTTP status code. # TYPE promhttp_metric_handler_requests_total counter promhttp_metric_handler_requests_total{code="200"} 1 promhttp_metric_handler_requests_total{code="500"} 0 promhttp_metric_handler_requests_total{code="503"} 0 # HELP river_insert_count_total Number of jobs inserted # TYPE river_insert_count_total counter river_insert_count_total{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok"} 1 # HELP river_insert_many_count_total Number of job batches inserted (all jobs are inserted in a batch, but batches may be one job) # TYPE river_insert_many_count_total counter river_insert_many_count_total{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok"} 1 # HELP river_insert_many_duration_histogram_seconds Duration of job batch insertion (histogram) # TYPE river_insert_many_duration_histogram_seconds histogram river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="0"} 0 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="5"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="10"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="25"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="50"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="75"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="100"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="250"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="500"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="750"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="1000"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="2500"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="5000"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="7500"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="10000"} 1 river_insert_many_duration_histogram_seconds_bucket{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok",le="+Inf"} 1 river_insert_many_duration_histogram_seconds_sum{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok"} 0.002905666 river_insert_many_duration_histogram_seconds_count{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok"} 1 # HELP river_insert_many_duration_seconds Duration of job batch insertion # TYPE river_insert_many_duration_seconds gauge river_insert_many_duration_seconds{otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",status="ok"} 0.002905666 # HELP river_work_count_total Number of jobs worked # TYPE river_work_count_total counter river_work_count_total{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 1 river_work_count_total{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 1 # HELP river_work_duration_histogram_seconds Duration of job being worked (histogram) # TYPE river_work_duration_histogram_seconds histogram river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="0"} 0 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="5"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="10"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="25"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="50"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="75"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="100"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="250"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="500"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="750"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="1000"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="2500"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="5000"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="7500"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="10000"} 1 river_work_duration_histogram_seconds_bucket{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="+Inf"} 1 river_work_duration_histogram_seconds_sum{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 0.029241083 river_work_duration_histogram_seconds_count{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="0"} 0 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="5"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="10"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="25"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="50"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="75"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="100"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="250"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="500"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="750"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="1000"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="2500"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="5000"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="7500"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="10000"} 1 river_work_duration_histogram_seconds_bucket{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]",le="+Inf"} 1 river_work_duration_histogram_seconds_sum{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 0.0408745 river_work_duration_histogram_seconds_count{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 1 # HELP river_work_duration_seconds Duration of job being worked # TYPE river_work_duration_seconds gauge river_work_duration_seconds{attempt="1",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 0.029241083 river_work_duration_seconds{attempt="2",kind="notification_request",otel_scope_name="github.com/riverqueue/rivercontrib/otelriver",otel_scope_version="",priority="1",queue="notification",status="error",tag="[]"} 0.0408745 # HELP target_info Target metadata # TYPE target_info gauge target_info{service_name="ZITADEL",service_version="2025-06-09T13:52:29-04:00",telemetry_sdk_language="go",telemetry_sdk_name="opentelemetry",telemetry_sdk_version="1.35.0"} 1
Example grafana dashboard: ![Screenshot 2025-06-11 at 11 30 06 AM](https://github.com/user-attachments/assets/a2c9b377-8ddd-40b9-a506-7df3b31941da) - Closes #10043 --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- go.mod | 1 + go.sum | 2 ++ internal/queue/queue.go | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/go.mod b/go.mod index 21a7fe9f16..15b3d2b391 100644 --- a/go.mod +++ b/go.mod @@ -146,6 +146,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/riverqueue/river/rivershared v0.22.0 // indirect + github.com/riverqueue/rivercontrib/otelriver v0.5.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index cc3bc35841..272ce655a3 100644 --- a/go.sum +++ b/go.sum @@ -682,6 +682,8 @@ github.com/riverqueue/river/rivershared v0.22.0 h1:hLPHr98d6OEfmUJ4KpIXgoy2tbQ14 github.com/riverqueue/river/rivershared v0.22.0/go.mod h1:BK+hvhECfdDLWNDH3xiGI95m2YoPfVtECZLT+my8XM8= github.com/riverqueue/river/rivertype v0.22.0 h1:rSRhbd5uV/BaFTPxReCxuYTAzx+/riBZJlZdREADvO4= github.com/riverqueue/river/rivertype v0.22.0/go.mod h1:lmdl3vLNDfchDWbYdW2uAocIuwIN+ZaXqAukdSCFqWs= +github.com/riverqueue/rivercontrib/otelriver v0.5.0 h1:dZF4Fy7/3RaIRsyCPdpIJtzEip0pCvoJ44YpSDum8e4= +github.com/riverqueue/rivercontrib/otelriver v0.5.0/go.mod h1:rXANcBrlgRvg+auD3/O6Xfs59AWeWNpa/kim62mkxGo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= diff --git a/internal/queue/queue.go b/internal/queue/queue.go index d680221753..22df8c2b5c 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -7,9 +7,12 @@ import ( "github.com/riverqueue/river" "github.com/riverqueue/river/riverdriver" "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivertype" + "github.com/riverqueue/rivercontrib/otelriver" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/telemetry/metrics" ) // Queue abstracts the underlying queuing library @@ -27,12 +30,16 @@ type Config struct { } func NewQueue(config *Config) (_ *Queue, err error) { + middleware := []rivertype.Middleware{otelriver.NewMiddleware(&otelriver.MiddlewareConfig{ + MeterProvider: metrics.GetMetricsProvider(), + })} return &Queue{ driver: riverpgxv5.New(config.Client.Pool), config: &river.Config{ Workers: river.NewWorkers(), Queues: make(map[string]river.QueueConfig), JobTimeout: -1, + Middleware: middleware, }, }, nil } From cddbd3dd47d559dde2e2deb63be25bc93137826b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabienne=20B=C3=BChler?= Date: Tue, 17 Jun 2025 15:20:44 +0200 Subject: [PATCH 098/123] docs: Correct API docs of unlock user (#10064) # Which Problems Are Solved The API docs of unlock user show the description of the lock user. # How the Problems Are Solved Correct API docs for unlock user are added --- proto/zitadel/user/v2/user_service.proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 3fc81836d6..a416555905 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -701,7 +701,7 @@ service UserService { // Unlock user // - // The state of the user will be changed to 'locked'. The user will not be able to log in anymore. The endpoint returns an error if the user is already in the state 'locked'. Use this endpoint if the user should not be able to log in temporarily because of an event that happened (wrong password, etc.).. + // The state of the user will be changed to 'active'. The user will be able to log in again. The endpoint returns an error if the user is not in the state 'locked'. rpc UnlockUser(UnlockUserRequest) returns (UnlockUserResponse) { option (google.api.http) = { post: "/v2/users/{user_id}/unlock" From 28f7218ea1068a4bc7587166ce3a17af20ff4156 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Wed, 18 Jun 2025 13:24:39 +0200 Subject: [PATCH 099/123] feat: Hosted login translation API (#10011) # Which Problems Are Solved This PR implements https://github.com/zitadel/zitadel/issues/9850 # How the Problems Are Solved - New protobuf definition - Implementation of retrieval of system translations - Implementation of retrieval and persistence of organization and instance level translations # Additional Context - Closes #9850 # TODO - [x] Integration tests for Get and Set hosted login translation endpoints - [x] DB migration test - [x] Command function tests - [x] Command util functions tests - [x] Query function test - [x] Query util functions tests --- go.mod | 3 +- go.sum | 2 + internal/api/authz/context_mock.go | 24 +- .../v2/integration_test/query_test.go | 432 +++++ .../v2/integration_test/server_test.go | 9 +- .../v2/integration_test/settings_test.go | 371 +--- internal/api/grpc/settings/v2/query.go | 209 +++ internal/api/grpc/settings/v2/settings.go | 201 +-- internal/command/hosted_login_translation.go | 73 + .../command/hosted_login_translation_model.go | 45 + .../command/hosted_login_translation_test.go | 211 +++ internal/database/mock/sql_mock.go | 12 +- internal/query/hosted_login_translation.go | 256 +++ .../query/hosted_login_translation_test.go | 337 ++++ .../projection/hosted_login_translation.go | 144 ++ internal/query/projection/projection.go | 3 + internal/query/v2-default.json | 1557 +++++++++++++++++ internal/repository/instance/eventstore.go | 1 + .../instance/hosted_login_translation.go | 55 + internal/repository/org/eventstore.go | 1 + .../org/hosted_login_translation.go | 55 + proto/zitadel/settings/v2/settings.proto | 2 +- .../settings/v2/settings_service.proto | 137 ++ 23 files changed, 3613 insertions(+), 527 deletions(-) create mode 100644 internal/api/grpc/settings/v2/integration_test/query_test.go create mode 100644 internal/api/grpc/settings/v2/query.go create mode 100644 internal/command/hosted_login_translation.go create mode 100644 internal/command/hosted_login_translation_model.go create mode 100644 internal/command/hosted_login_translation_test.go create mode 100644 internal/query/hosted_login_translation.go create mode 100644 internal/query/hosted_login_translation_test.go create mode 100644 internal/query/projection/hosted_login_translation.go create mode 100644 internal/query/v2-default.json create mode 100644 internal/repository/instance/hosted_login_translation.go create mode 100644 internal/repository/org/hosted_login_translation.go diff --git a/go.mod b/go.mod index 15b3d2b391..9d02050b48 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.24.1 require ( cloud.google.com/go/profiler v0.4.2 cloud.google.com/go/storage v1.54.0 + dario.cat/mergo v1.0.2 github.com/BurntSushi/toml v1.5.0 github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.27.0 @@ -65,6 +66,7 @@ require ( github.com/riverqueue/river v0.22.0 github.com/riverqueue/river/riverdriver v0.22.0 github.com/riverqueue/river/rivertype v0.22.0 + github.com/riverqueue/rivercontrib/otelriver v0.5.0 github.com/rs/cors v1.11.1 github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 github.com/shopspring/decimal v1.3.1 @@ -146,7 +148,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/riverqueue/river/rivershared v0.22.0 // indirect - github.com/riverqueue/rivercontrib/otelriver v0.5.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index 272ce655a3..e2ab9768a6 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= diff --git a/internal/api/authz/context_mock.go b/internal/api/authz/context_mock.go index 6891030bd3..d26b371bc6 100644 --- a/internal/api/authz/context_mock.go +++ b/internal/api/authz/context_mock.go @@ -1,10 +1,28 @@ package authz -import "context" +import ( + "context" -func NewMockContext(instanceID, orgID, userID string) context.Context { + "golang.org/x/text/language" +) + +type MockContextInstanceOpts func(i *instance) + +func WithMockDefaultLanguage(lang language.Tag) MockContextInstanceOpts { + return func(i *instance) { + i.defaultLanguage = lang + } +} + +func NewMockContext(instanceID, orgID, userID string, opts ...MockContextInstanceOpts) context.Context { ctx := context.WithValue(context.Background(), dataKey, CtxData{UserID: userID, OrgID: orgID}) - return context.WithValue(ctx, instanceKey, &instance{id: instanceID}) + + i := &instance{id: instanceID} + for _, o := range opts { + o(i) + } + + return context.WithValue(ctx, instanceKey, i) } func NewMockContextWithAgent(instanceID, orgID, userID, agentID string) context.Context { diff --git a/internal/api/grpc/settings/v2/integration_test/query_test.go b/internal/api/grpc/settings/v2/integration_test/query_test.go new file mode 100644 index 0000000000..c3bf54e992 --- /dev/null +++ b/internal/api/grpc/settings/v2/integration_test/query_test.go @@ -0,0 +1,432 @@ +//go:build integration + +package settings_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/idp" + idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestServer_GetSecuritySettings(t *testing.T) { + _, err := Client.SetSecuritySettings(AdminCTX, &settings.SetSecuritySettingsRequest{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + }) + require.NoError(t, err) + + tests := []struct { + name string + ctx context.Context + want *settings.GetSecuritySettingsResponse + wantErr bool + }{ + { + name: "permission error", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + wantErr: true, + }, + { + name: "success", + ctx: AdminCTX, + want: &settings.GetSecuritySettingsResponse{ + Settings: &settings.SecuritySettings{ + EmbeddedIframe: &settings.EmbeddedIframeSettings{ + Enabled: true, + AllowedOrigins: []string{"foo", "bar"}, + }, + EnableImpersonation: true, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{}) + if tt.wantErr { + assert.Error(ct, err) + return + } + if !assert.NoError(ct, err) { + return + } + got, want := resp.GetSettings(), tt.want.GetSettings() + assert.Equal(ct, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding") + assert.Equal(ct, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins") + assert.Equal(ct, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation") + }, retryDuration, tick) + }) + } +} + +func idpResponse(id, name string, linking, creation, autoCreation, autoUpdate bool, autoLinking idp_pb.AutoLinkingOption) *settings.IdentityProvider { + return &settings.IdentityProvider{ + Id: id, + Name: name, + Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, + Options: &idp_pb.Options{ + IsLinkingAllowed: linking, + IsCreationAllowed: creation, + IsAutoCreation: autoCreation, + IsAutoUpdate: autoUpdate, + AutoLinking: autoLinking, + }, + } +} + +func TestServer_GetActiveIdentityProviders(t *testing.T) { + instance := integration.NewInstance(CTX) + isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, gofakeit.AppName()) // inactive + idpActiveName := gofakeit.AppName() + idpActiveResp := instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, idpActiveName) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpActiveResp.GetId()) + idpActiveResponse := idpResponse(idpActiveResp.GetId(), idpActiveName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpLinkingDisallowedName := gofakeit.AppName() + idpLinkingDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpLinkingDisallowedName, false, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpLinkingDisallowedResp.GetId()) + idpLinkingDisallowedResponse := idpResponse(idpLinkingDisallowedResp.GetId(), idpLinkingDisallowedName, false, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpCreationDisallowedName := gofakeit.AppName() + idpCreationDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpCreationDisallowedName, true, false, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpCreationDisallowedResp.GetId()) + idpCreationDisallowedResponse := idpResponse(idpCreationDisallowedResp.GetId(), idpCreationDisallowedName, true, false, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoCreationName := gofakeit.AppName() + idpNoAutoCreationResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoCreationName, true, true, false, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoCreationResp.GetId()) + idpNoAutoCreationResponse := idpResponse(idpNoAutoCreationResp.GetId(), idpNoAutoCreationName, true, true, false, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) + idpNoAutoLinkingName := gofakeit.AppName() + idpNoAutoLinkingResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoLinkingName, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoLinkingResp.GetId()) + idpNoAutoLinkingResponse := idpResponse(idpNoAutoLinkingResp.GetId(), idpNoAutoLinkingName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + + type args struct { + ctx context.Context + req *settings.GetActiveIdentityProvidersRequest + } + tests := []struct { + name string + args args + want *settings.GetActiveIdentityProvidersResponse + wantErr bool + }{ + { + name: "permission error", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + wantErr: true, + }, + { + name: "success, all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{}, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 5, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only linking disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpLinkingDisallowedResponse, + }, + }, + }, + { + name: "success, exclude creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpNoAutoCreationResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, only creation disallowed", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + CreationAllowed: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpCreationDisallowedResponse, + }, + }, + }, + { + name: "success, auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, no auto creation", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoCreation: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 4, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + idpLinkingDisallowedResponse, + idpCreationDisallowedResponse, + idpNoAutoCreationResponse, + }, + }, + }, + { + name: "success, no auto linking", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + AutoLinking: gu.Ptr(false), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpNoAutoLinkingResponse, + }, + }, + }, + { + name: "success, exclude all", + args: args{ + ctx: isolatedIAMOwnerCTX, + req: &settings.GetActiveIdentityProvidersRequest{ + LinkingAllowed: gu.Ptr(true), + CreationAllowed: gu.Ptr(true), + AutoCreation: gu.Ptr(true), + AutoLinking: gu.Ptr(true), + }, + }, + want: &settings.GetActiveIdentityProvidersResponse{ + Details: &object_pb.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + IdentityProviders: []*settings.IdentityProvider{ + idpActiveResponse, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) + assert.EventuallyWithT(t, func(ct *assert.CollectT) { + got, err := instance.Client.SettingsV2.GetActiveIdentityProviders(tt.args.ctx, tt.args.req) + if tt.wantErr { + assert.Error(ct, err) + return + } + if !assert.NoError(ct, err) { + return + } + for i, result := range tt.want.GetIdentityProviders() { + assert.EqualExportedValues(ct, result, got.GetIdentityProviders()[i]) + } + integration.AssertListDetails(ct, tt.want, got) + }, retryDuration, tick) + }) + } +} + +func TestServer_GetHostedLoginTranslation(t *testing.T) { + // Given + translations := map[string]any{"loginTitle": gofakeit.Slogan()} + + protoTranslations, err := structpb.NewStruct(translations) + require.NoError(t, err) + + setupRequest := &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), + }, + Translations: protoTranslations, + Locale: gofakeit.LanguageBCP(), + } + savedTranslation, err := Client.SetHostedLoginTranslation(AdminCTX, setupRequest) + require.NoError(t, err) + + tt := []struct { + testName string + inputCtx context.Context + inputRequest *settings.GetHostedLoginTranslationRequest + + expectedErrorCode codes.Code + expectedErrorMsg string + expectedResponse *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when unauthN context should return unauthN error", + inputCtx: CTX, + inputRequest: &settings.GetHostedLoginTranslationRequest{Locale: "en-US"}, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", + }, + { + testName: "when unauthZ context should return unauthZ error", + inputCtx: OrgOwnerCtx, + inputRequest: &settings.GetHostedLoginTranslationRequest{Locale: "en-US"}, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", + }, + { + testName: "when authZ request should save to db and return etag", + inputCtx: AdminCTX, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), + }, + Locale: setupRequest.GetLocale(), + }, + expectedResponse: &settings.GetHostedLoginTranslationResponse{ + Etag: savedTranslation.GetEtag(), + Translations: protoTranslations, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // When + res, err := Client.GetHostedLoginTranslation(tc.inputCtx, tc.inputRequest) + + // Then + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.NotEmpty(t, res.GetEtag()) + assert.NotEmpty(t, res.GetTranslations().GetFields()) + } + }) + } +} diff --git a/internal/api/grpc/settings/v2/integration_test/server_test.go b/internal/api/grpc/settings/v2/integration_test/server_test.go index d57e2a7694..c5c851c310 100644 --- a/internal/api/grpc/settings/v2/integration_test/server_test.go +++ b/internal/api/grpc/settings/v2/integration_test/server_test.go @@ -13,9 +13,9 @@ import ( ) var ( - CTX, AdminCTX context.Context - Instance *integration.Instance - Client settings.SettingsServiceClient + CTX, AdminCTX, UserTypeLoginCtx, OrgOwnerCtx context.Context + Instance *integration.Instance + Client settings.SettingsServiceClient ) func TestMain(m *testing.M) { @@ -27,6 +27,9 @@ func TestMain(m *testing.M) { CTX = ctx AdminCTX = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + UserTypeLoginCtx = Instance.WithAuthorization(ctx, integration.UserTypeLogin) + OrgOwnerCtx = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + Client = Instance.Client.SettingsV2 return m.Run() }()) diff --git a/internal/api/grpc/settings/v2/integration_test/settings_test.go b/internal/api/grpc/settings/v2/integration_test/settings_test.go index 3430eae5f8..7d1e4b0239 100644 --- a/internal/api/grpc/settings/v2/integration_test/settings_test.go +++ b/internal/api/grpc/settings/v2/integration_test/settings_test.go @@ -4,78 +4,23 @@ package settings_test import ( "context" + "crypto/md5" + "encoding/hex" + "fmt" "testing" - "time" - "github.com/brianvoe/gofakeit/v6" - "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/idp" - idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func TestServer_GetSecuritySettings(t *testing.T) { - _, err := Client.SetSecuritySettings(AdminCTX, &settings.SetSecuritySettingsRequest{ - EmbeddedIframe: &settings.EmbeddedIframeSettings{ - Enabled: true, - AllowedOrigins: []string{"foo", "bar"}, - }, - EnableImpersonation: true, - }) - require.NoError(t, err) - - tests := []struct { - name string - ctx context.Context - want *settings.GetSecuritySettingsResponse - wantErr bool - }{ - { - name: "permission error", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), - wantErr: true, - }, - { - name: "success", - ctx: AdminCTX, - want: &settings.GetSecuritySettingsResponse{ - Settings: &settings.SecuritySettings{ - EmbeddedIframe: &settings.EmbeddedIframeSettings{ - Enabled: true, - AllowedOrigins: []string{"foo", "bar"}, - }, - EnableImpersonation: true, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.ctx, time.Minute) - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - resp, err := Client.GetSecuritySettings(tt.ctx, &settings.GetSecuritySettingsRequest{}) - if tt.wantErr { - assert.Error(ct, err) - return - } - if !assert.NoError(ct, err) { - return - } - got, want := resp.GetSettings(), tt.want.GetSettings() - assert.Equal(ct, want.GetEmbeddedIframe().GetEnabled(), got.GetEmbeddedIframe().GetEnabled(), "enable iframe embedding") - assert.Equal(ct, want.GetEmbeddedIframe().GetAllowedOrigins(), got.GetEmbeddedIframe().GetAllowedOrigins(), "allowed origins") - assert.Equal(ct, want.GetEnableImpersonation(), got.GetEnableImpersonation(), "enable impersonation") - }, retryDuration, tick) - }) - } -} - func TestServer_SetSecuritySettings(t *testing.T) { type args struct { ctx context.Context @@ -183,280 +128,64 @@ func TestServer_SetSecuritySettings(t *testing.T) { } } -func idpResponse(id, name string, linking, creation, autoCreation, autoUpdate bool, autoLinking idp_pb.AutoLinkingOption) *settings.IdentityProvider { - return &settings.IdentityProvider{ - Id: id, - Name: name, - Type: settings.IdentityProviderType_IDENTITY_PROVIDER_TYPE_OAUTH, - Options: &idp_pb.Options{ - IsLinkingAllowed: linking, - IsCreationAllowed: creation, - IsAutoCreation: autoCreation, - IsAutoUpdate: autoUpdate, - AutoLinking: autoLinking, - }, - } -} +func TestSetHostedLoginTranslation(t *testing.T) { + translations := map[string]any{"loginTitle": "Welcome to our service"} -func TestServer_GetActiveIdentityProviders(t *testing.T) { - instance := integration.NewInstance(CTX) - isolatedIAMOwnerCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + protoTranslations, err := structpb.NewStruct(translations) + require.Nil(t, err) - instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, gofakeit.AppName()) // inactive - idpActiveName := gofakeit.AppName() - idpActiveResp := instance.AddGenericOAuthProvider(isolatedIAMOwnerCTX, idpActiveName) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpActiveResp.GetId()) - idpActiveResponse := idpResponse(idpActiveResp.GetId(), idpActiveName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpLinkingDisallowedName := gofakeit.AppName() - idpLinkingDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpLinkingDisallowedName, false, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpLinkingDisallowedResp.GetId()) - idpLinkingDisallowedResponse := idpResponse(idpLinkingDisallowedResp.GetId(), idpLinkingDisallowedName, false, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpCreationDisallowedName := gofakeit.AppName() - idpCreationDisallowedResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpCreationDisallowedName, true, false, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpCreationDisallowedResp.GetId()) - idpCreationDisallowedResponse := idpResponse(idpCreationDisallowedResp.GetId(), idpCreationDisallowedName, true, false, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpNoAutoCreationName := gofakeit.AppName() - idpNoAutoCreationResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoCreationName, true, true, false, idp.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoCreationResp.GetId()) - idpNoAutoCreationResponse := idpResponse(idpNoAutoCreationResp.GetId(), idpNoAutoCreationName, true, true, false, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_USERNAME) - idpNoAutoLinkingName := gofakeit.AppName() - idpNoAutoLinkingResp := instance.AddGenericOAuthProviderWithOptions(isolatedIAMOwnerCTX, idpNoAutoLinkingName, true, true, true, idp.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) - instance.AddProviderToDefaultLoginPolicy(isolatedIAMOwnerCTX, idpNoAutoLinkingResp.GetId()) - idpNoAutoLinkingResponse := idpResponse(idpNoAutoLinkingResp.GetId(), idpNoAutoLinkingName, true, true, true, true, idp_pb.AutoLinkingOption_AUTO_LINKING_OPTION_UNSPECIFIED) + hash := md5.Sum(fmt.Append(nil, translations)) - type args struct { - ctx context.Context - req *settings.GetActiveIdentityProvidersRequest - } - tests := []struct { - name string - args args - want *settings.GetActiveIdentityProvidersResponse - wantErr bool + tt := []struct { + testName string + inputCtx context.Context + inputRequest *settings.SetHostedLoginTranslationRequest + + expectedErrorCode codes.Code + expectedErrorMsg string + expectedResponse *settings.SetHostedLoginTranslationResponse }{ { - name: "permission error", - args: args{ - ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - req: &settings.GetActiveIdentityProvidersRequest{}, - }, - wantErr: true, + testName: "when unauthN context should return unauthN error", + inputCtx: CTX, + expectedErrorCode: codes.Unauthenticated, + expectedErrorMsg: "auth header missing", }, { - name: "success, all", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{}, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 5, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, + testName: "when unauthZ context should return unauthZ error", + inputCtx: UserTypeLoginCtx, + expectedErrorCode: codes.PermissionDenied, + expectedErrorMsg: "No matching permissions found (AUTH-5mWD2)", }, { - name: "success, exclude linking disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(true), + testName: "when authZ request should save to db and return etag", + inputCtx: AdminCTX, + inputRequest: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: Instance.DefaultOrg.GetId(), }, + Translations: protoTranslations, + Locale: "en-US", }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, only linking disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpLinkingDisallowedResponse, - }, - }, - }, - { - name: "success, exclude creation disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - CreationAllowed: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpNoAutoCreationResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, only creation disallowed", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - CreationAllowed: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpCreationDisallowedResponse, - }, - }, - }, - { - name: "success, auto creation", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoCreation: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, no auto creation", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoCreation: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpNoAutoCreationResponse, - }, - }, - }, - { - name: "success, auto linking", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoLinking: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 4, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - idpLinkingDisallowedResponse, - idpCreationDisallowedResponse, - idpNoAutoCreationResponse, - }, - }, - }, - { - name: "success, no auto linking", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - AutoLinking: gu.Ptr(false), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpNoAutoLinkingResponse, - }, - }, - }, - { - name: "success, exclude all", - args: args{ - ctx: isolatedIAMOwnerCTX, - req: &settings.GetActiveIdentityProvidersRequest{ - LinkingAllowed: gu.Ptr(true), - CreationAllowed: gu.Ptr(true), - AutoCreation: gu.Ptr(true), - AutoLinking: gu.Ptr(true), - }, - }, - want: &settings.GetActiveIdentityProvidersResponse{ - Details: &object_pb.ListDetails{ - TotalResult: 1, - Timestamp: timestamppb.Now(), - }, - IdentityProviders: []*settings.IdentityProvider{ - idpActiveResponse, - }, + expectedResponse: &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(hash[:]), }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tt.args.ctx, time.Minute) - assert.EventuallyWithT(t, func(ct *assert.CollectT) { - got, err := instance.Client.SettingsV2.GetActiveIdentityProviders(tt.args.ctx, tt.args.req) - if tt.wantErr { - assert.Error(ct, err) - return - } - if !assert.NoError(ct, err) { - return - } - for i, result := range tt.want.GetIdentityProviders() { - assert.EqualExportedValues(ct, result, got.GetIdentityProviders()[i]) - } - integration.AssertListDetails(ct, tt.want, got) - }, retryDuration, tick) + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // When + res, err := Client.SetHostedLoginTranslation(tc.inputCtx, tc.inputRequest) + + // Then + assert.Equal(t, tc.expectedErrorCode, status.Code(err)) + assert.Equal(t, tc.expectedErrorMsg, status.Convert(err).Message()) + + if tc.expectedErrorMsg == "" { + require.NoError(t, err) + assert.Equal(t, tc.expectedResponse.GetEtag(), res.GetEtag()) + } }) } } diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go new file mode 100644 index 0000000000..b8994ccb87 --- /dev/null +++ b/internal/api/grpc/settings/v2/query.go @@ -0,0 +1,209 @@ +package settings + +import ( + "context" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/query" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetLoginSettingsResponse{ + Settings: loginSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.OrgID, + }, + }, nil +} + +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetPasswordComplexitySettingsResponse{ + Settings: passwordComplexitySettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetPasswordExpirySettingsResponse{ + Settings: passwordExpirySettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetBrandingSettingsResponse{ + Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetDomainSettingsResponse{ + Settings: domainSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) + if err != nil { + return nil, err + } + return &settings.GetLegalAndSupportSettingsResponse{ + Settings: legalAndSupportSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) + if err != nil { + return nil, err + } + return &settings.GetLockoutSettingsResponse{ + Settings: lockoutSettingsToPb(current), + Details: &object_pb.Details{ + Sequence: current.Sequence, + CreationDate: timestamppb.New(current.CreationDate), + ChangeDate: timestamppb.New(current.ChangeDate), + ResourceOwner: current.ResourceOwner, + }, + }, nil +} + +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { + queries, err := activeIdentityProvidersToQuery(req) + if err != nil { + return nil, err + } + + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) + if err != nil { + return nil, err + } + + return &settings.GetActiveIdentityProvidersResponse{ + Details: object.ToListDetails(links.SearchResponse), + IdentityProviders: identityProvidersToPb(links.Links), + }, nil +} + +func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, 0, 4) + if req.CreationAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsCreationAllowedSearchQuery(*req.CreationAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.LinkingAllowed != nil { + creationQuery, err := query.NewIDPTemplateIsLinkingAllowedSearchQuery(*req.LinkingAllowed) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoCreation != nil { + creationQuery, err := query.NewIDPTemplateIsAutoCreationSearchQuery(*req.AutoCreation) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + if req.AutoLinking != nil { + compare := query.NumberEquals + if *req.AutoLinking { + compare = query.NumberNotEquals + } + creationQuery, err := query.NewIDPTemplateAutoLinkingSearchQuery(0, compare) + if err != nil { + return nil, err + } + q = append(q, creationQuery) + } + return q, nil +} + +func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { + instance := authz.GetInstance(ctx) + return &settings.GetGeneralSettingsResponse{ + SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), + DefaultOrgId: instance.DefaultOrganisationID(), + DefaultLanguage: instance.DefaultLanguage().String(), + }, nil +} + +func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { + policy, err := s.query.SecurityPolicy(ctx) + if err != nil { + return nil, err + } + return &settings.GetSecuritySettingsResponse{ + Settings: securityPolicyToSettingsPb(policy), + }, nil +} + +func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (*settings.GetHostedLoginTranslationResponse, error) { + translation, err := s.query.GetHostedLoginTranslation(ctx, req) + if err != nil { + return nil, err + } + + return translation, nil +} diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 77874bf970..09ee6b27c8 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -3,202 +3,10 @@ package settings import ( "context" - "google.golang.org/protobuf/types/known/timestamppb" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/i18n" - "github.com/zitadel/zitadel/internal/query" - object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetLoginSettingsResponse{ - Settings: loginSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.OrgID, - }, - }, nil -} - -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetPasswordComplexitySettingsResponse{ - Settings: passwordComplexitySettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetPasswordExpirySettingsResponse{ - Settings: passwordExpirySettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetBrandingSettingsResponse{ - Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetDomainSettingsResponse{ - Settings: domainSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) - if err != nil { - return nil, err - } - return &settings.GetLegalAndSupportSettingsResponse{ - Settings: legalAndSupportSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) - if err != nil { - return nil, err - } - return &settings.GetLockoutSettingsResponse{ - Settings: lockoutSettingsToPb(current), - Details: &object_pb.Details{ - Sequence: current.Sequence, - CreationDate: timestamppb.New(current.CreationDate), - ChangeDate: timestamppb.New(current.ChangeDate), - ResourceOwner: current.ResourceOwner, - }, - }, nil -} - -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - queries, err := activeIdentityProvidersToQuery(req) - if err != nil { - return nil, err - } - - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) - if err != nil { - return nil, err - } - - return &settings.GetActiveIdentityProvidersResponse{ - Details: object.ToListDetails(links.SearchResponse), - IdentityProviders: identityProvidersToPb(links.Links), - }, nil -} - -func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { - q := make([]query.SearchQuery, 0, 4) - if req.CreationAllowed != nil { - creationQuery, err := query.NewIDPTemplateIsCreationAllowedSearchQuery(*req.CreationAllowed) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.LinkingAllowed != nil { - creationQuery, err := query.NewIDPTemplateIsLinkingAllowedSearchQuery(*req.LinkingAllowed) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.AutoCreation != nil { - creationQuery, err := query.NewIDPTemplateIsAutoCreationSearchQuery(*req.AutoCreation) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - if req.AutoLinking != nil { - compare := query.NumberEquals - if *req.AutoLinking { - compare = query.NumberNotEquals - } - creationQuery, err := query.NewIDPTemplateAutoLinkingSearchQuery(0, compare) - if err != nil { - return nil, err - } - q = append(q, creationQuery) - } - return q, nil -} - -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { - instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ - SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), - DefaultOrgId: instance.DefaultOrganisationID(), - DefaultLanguage: instance.DefaultLanguage().String(), - }, nil -} - -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { - policy, err := s.query.SecurityPolicy(ctx) - if err != nil { - return nil, err - } - return &settings.GetSecuritySettingsResponse{ - Settings: securityPolicyToSettingsPb(policy), - }, nil -} - func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) if err != nil { @@ -208,3 +16,12 @@ func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecur Details: object.DomainToDetailsPb(details), }, nil } + +func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (*settings.SetHostedLoginTranslationResponse, error) { + res, err := s.command.SetHostedLoginTranslation(ctx, req) + if err != nil { + return nil, err + } + + return res, nil +} diff --git a/internal/command/hosted_login_translation.go b/internal/command/hosted_login_translation.go new file mode 100644 index 0000000000..024ab6bdad --- /dev/null +++ b/internal/command/hosted_login_translation.go @@ -0,0 +1,73 @@ +package command + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func (c *Commands) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (res *settings.SetHostedLoginTranslationResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + var agg eventstore.Aggregate + switch t := req.GetLevel().(type) { + case *settings.SetHostedLoginTranslationRequest_Instance: + agg = instance.NewAggregate(authz.GetInstance(ctx).InstanceID()).Aggregate + case *settings.SetHostedLoginTranslationRequest_OrganizationId: + agg = org.NewAggregate(t.OrganizationId).Aggregate + default: + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-YB6Sri", "Errors.Arguments.Level.Invalid") + } + + lang, err := language.Parse(req.GetLocale()) + if err != nil || lang.IsRoot() { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid") + } + + commands, wm, err := c.setTranslationEvents(ctx, agg, lang, req.GetTranslations().AsMap()) + if err != nil { + return nil, err + } + + pushedEvents, err := c.eventstore.Push(ctx, commands...) + if err != nil { + return nil, zerrors.ThrowInternal(err, "COMMA-i8nqFl", "Errors.Internal") + } + + err = AppendAndReduce(wm, pushedEvents...) + if err != nil { + return nil, err + } + + etag := md5.Sum(fmt.Append(nil, wm.Translation)) + return &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(etag[:]), + }, nil +} + +func (c *Commands) setTranslationEvents(ctx context.Context, agg eventstore.Aggregate, lang language.Tag, translations map[string]any) ([]eventstore.Command, *HostedLoginTranslationWriteModel, error) { + wm := NewHostedLoginTranslationWriteModel(agg.ID) + events := []eventstore.Command{} + switch agg.Type { + case instance.AggregateType: + events = append(events, instance.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang)) + case org.AggregateType: + events = append(events, org.NewHostedLoginTranslationSetEvent(ctx, &agg, translations, lang)) + default: + return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid") + } + + return events, wm, nil +} diff --git a/internal/command/hosted_login_translation_model.go b/internal/command/hosted_login_translation_model.go new file mode 100644 index 0000000000..16bc42c541 --- /dev/null +++ b/internal/command/hosted_login_translation_model.go @@ -0,0 +1,45 @@ +package command + +import ( + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" +) + +type HostedLoginTranslationWriteModel struct { + eventstore.WriteModel + Language language.Tag + Translation map[string]any + Level string + LevelID string +} + +func NewHostedLoginTranslationWriteModel(resourceID string) *HostedLoginTranslationWriteModel { + return &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: resourceID, + ResourceOwner: resourceID, + }, + } +} + +func (wm *HostedLoginTranslationWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *org.HostedLoginTranslationSetEvent: + wm.Language = e.Language + wm.Translation = e.Translation + wm.Level = e.Level + wm.LevelID = e.Aggregate().ID + case *instance.HostedLoginTranslationSetEvent: + wm.Language = e.Language + wm.Translation = e.Translation + wm.Level = e.Level + wm.LevelID = e.Aggregate().ID + } + } + + return wm.WriteModel.Reduce() +} diff --git a/internal/command/hosted_login_translation_test.go b/internal/command/hosted_login_translation_test.go new file mode 100644 index 0000000000..a5f0941711 --- /dev/null +++ b/internal/command/hosted_login_translation_test.go @@ -0,0 +1,211 @@ +package command + +import ( + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/service" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestSetTranslationEvents(t *testing.T) { + t.Parallel() + + testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"}) + testCtx = service.WithService(testCtx, "test-service") + + tt := []struct { + testName string + + inputAggregate eventstore.Aggregate + inputLanguage language.Tag + inputTranslations map[string]any + + expectedCommands []eventstore.Command + expectedWriteModel *HostedLoginTranslationWriteModel + expectedError error + }{ + { + testName: "when aggregate type is instance should return matching write model and instance.hosted_login_translation_set event", + inputAggregate: eventstore.Aggregate{ID: "123", Type: instance.AggregateType}, + inputLanguage: language.MustParse("en-US"), + inputTranslations: map[string]any{"test": "translation"}, + expectedCommands: []eventstore.Command{ + instance.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: instance.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-US")), + }, + expectedWriteModel: &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"}, + }, + }, + { + testName: "when aggregate type is org should return matching write model and org.hosted_login_translation_set event", + inputAggregate: eventstore.Aggregate{ID: "123", Type: org.AggregateType}, + inputLanguage: language.MustParse("en-GB"), + inputTranslations: map[string]any{"test": "translation"}, + expectedCommands: []eventstore.Command{ + org.NewHostedLoginTranslationSetEvent(testCtx, &eventstore.Aggregate{ID: "123", Type: org.AggregateType}, map[string]any{"test": "translation"}, language.MustParse("en-GB")), + }, + expectedWriteModel: &HostedLoginTranslationWriteModel{ + WriteModel: eventstore.WriteModel{AggregateID: "123", ResourceOwner: "123"}, + }, + }, + { + testName: "when aggregate type is neither org nor instance should return invalid argument error", + inputAggregate: eventstore.Aggregate{ID: "123"}, + inputLanguage: language.MustParse("en-US"), + inputTranslations: map[string]any{"test": "translation"}, + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-0aw7In", "Errors.Arguments.LevelType.Invalid"), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := Commands{} + + // When + events, writeModel, err := c.setTranslationEvents(testCtx, tc.inputAggregate, tc.inputLanguage, tc.inputTranslations) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedWriteModel, writeModel) + + require.Len(t, events, len(tc.expectedCommands)) + assert.ElementsMatch(t, tc.expectedCommands, events) + }) + } +} + +func TestSetHostedLoginTranslation(t *testing.T) { + t.Parallel() + + testCtx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "test-user"}) + testCtx = service.WithService(testCtx, "test-service") + testCtx = authz.WithInstanceID(testCtx, "instance-id") + + testTranslation := map[string]any{"test": "translation", "translation": "2"} + protoTranslation, err := structpb.NewStruct(testTranslation) + require.NoError(t, err) + + hashTestTranslation := md5.Sum(fmt.Append(nil, testTranslation)) + require.NotEmpty(t, hashTestTranslation) + + tt := []struct { + testName string + + mockPush func(*testing.T) *eventstore.Eventstore + + inputReq *settings.SetHostedLoginTranslationRequest + + expectedError error + expectedResult *settings.SetHostedLoginTranslationResponse + }{ + { + testName: "when locale is malformed should return invalid argument error", + mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "123", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when locale is unknown should return invalid argument error", + mockPush: func(t *testing.T) *eventstore.Eventstore { return &eventstore.Eventstore{} }, + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "root", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "COMMA-xmjATA", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when event pushing fails should return internal error", + + mockPush: expectEventstore(expectPushFailed( + errors.New("mock push failed"), + instance.NewHostedLoginTranslationSetEvent( + testCtx, &eventstore.Aggregate{ + ID: "instance-id", + Type: instance.AggregateType, + ResourceOwner: "instance-id", + InstanceID: "instance-id", + Version: instance.AggregateVersion, + }, + testTranslation, + language.MustParse("it-CH"), + ), + )), + + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_Instance{}, + Locale: "it-CH", + Translations: protoTranslation, + }, + + expectedError: zerrors.ThrowInternal(errors.New("mock push failed"), "COMMA-i8nqFl", "Errors.Internal"), + }, + { + testName: "when request is valid should return expected response", + + mockPush: expectEventstore(expectPush( + org.NewHostedLoginTranslationSetEvent( + testCtx, &eventstore.Aggregate{ + ID: "org-id", + Type: org.AggregateType, + ResourceOwner: "org-id", + InstanceID: "", + Version: org.AggregateVersion, + }, + testTranslation, + language.MustParse("it-CH"), + ), + )), + + inputReq: &settings.SetHostedLoginTranslationRequest{ + Level: &settings.SetHostedLoginTranslationRequest_OrganizationId{OrganizationId: "org-id"}, + Locale: "it-CH", + Translations: protoTranslation, + }, + + expectedResult: &settings.SetHostedLoginTranslationResponse{ + Etag: hex.EncodeToString(hashTestTranslation[:]), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + c := Commands{ + eventstore: tc.mockPush(t), + } + + // When + res, err := c.SetHostedLoginTranslation(testCtx, tc.inputReq) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, res) + }) + } +} diff --git a/internal/database/mock/sql_mock.go b/internal/database/mock/sql_mock.go index b8030b269f..cd30cd9cf0 100644 --- a/internal/database/mock/sql_mock.go +++ b/internal/database/mock/sql_mock.go @@ -14,9 +14,9 @@ type SQLMock struct { mock sqlmock.Sqlmock } -type expectation func(m sqlmock.Sqlmock) +type Expectation func(m sqlmock.Sqlmock) -func NewSQLMock(t *testing.T, expectations ...expectation) *SQLMock { +func NewSQLMock(t *testing.T, expectations ...Expectation) *SQLMock { db, mock, err := sqlmock.New( sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual), sqlmock.ValueConverterOption(new(TypeConverter)), @@ -45,7 +45,7 @@ func (m *SQLMock) Assert(t *testing.T) { m.DB.Close() } -func ExpectBegin(err error) expectation { +func ExpectBegin(err error) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectBegin() if err != nil { @@ -54,7 +54,7 @@ func ExpectBegin(err error) expectation { } } -func ExpectCommit(err error) expectation { +func ExpectCommit(err error) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectCommit() if err != nil { @@ -89,7 +89,7 @@ func WithExecRowsAffected(affected driver.RowsAffected) ExecOpt { } } -func ExcpectExec(stmt string, opts ...ExecOpt) expectation { +func ExcpectExec(stmt string, opts ...ExecOpt) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectExec(stmt) for _, opt := range opts { @@ -122,7 +122,7 @@ func WithQueryResult(columns []string, rows [][]driver.Value) QueryOpt { } } -func ExpectQuery(stmt string, opts ...QueryOpt) expectation { +func ExpectQuery(stmt string, opts ...QueryOpt) Expectation { return func(m sqlmock.Sqlmock) { e := m.ExpectQuery(stmt) for _, opt := range opts { diff --git a/internal/query/hosted_login_translation.go b/internal/query/hosted_login_translation.go new file mode 100644 index 0000000000..82193d2069 --- /dev/null +++ b/internal/query/hosted_login_translation.go @@ -0,0 +1,256 @@ +package query + +import ( + "context" + "crypto/md5" + "database/sql" + _ "embed" + "encoding/hex" + "encoding/json" + "fmt" + "time" + + "dario.cat/mergo" + sq "github.com/Masterminds/squirrel" + "github.com/zitadel/logging" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/query/projection" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/v2/org" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +var ( + //go:embed v2-default.json + defaultLoginTranslations []byte + + defaultSystemTranslations map[language.Tag]map[string]any + + hostedLoginTranslationTable = table{ + name: projection.HostedLoginTranslationTable, + instanceIDCol: projection.HostedLoginTranslationInstanceIDCol, + } + + hostedLoginTranslationColInstanceID = Column{ + name: projection.HostedLoginTranslationInstanceIDCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColResourceOwner = Column{ + name: projection.HostedLoginTranslationAggregateIDCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColResourceOwnerType = Column{ + name: projection.HostedLoginTranslationAggregateTypeCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColLocale = Column{ + name: projection.HostedLoginTranslationLocaleCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColFile = Column{ + name: projection.HostedLoginTranslationFileCol, + table: hostedLoginTranslationTable, + } + hostedLoginTranslationColEtag = Column{ + name: projection.HostedLoginTranslationEtagCol, + table: hostedLoginTranslationTable, + } +) + +func init() { + err := json.Unmarshal(defaultLoginTranslations, &defaultSystemTranslations) + if err != nil { + panic(err) + } +} + +type HostedLoginTranslations struct { + SearchResponse + HostedLoginTranslations []*HostedLoginTranslation +} + +type HostedLoginTranslation struct { + AggregateID string + Sequence uint64 + CreationDate time.Time + ChangeDate time.Time + + Locale string + File map[string]any + LevelType string + LevelID string + Etag string +} + +func (q *Queries) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (res *settings.GetHostedLoginTranslationResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + inst := authz.GetInstance(ctx) + defaultInstLang := inst.DefaultLanguage() + + lang, err := language.BCP47.Parse(req.GetLocale()) + if err != nil || lang.IsRoot() { + return nil, zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid") + } + parentLang := lang.Parent() + if parentLang.IsRoot() { + parentLang = lang + } + + sysTranslation, systemEtag, err := getSystemTranslation(parentLang, defaultInstLang) + if err != nil { + return nil, err + } + + var levelID, resourceOwner string + switch t := req.GetLevel().(type) { + case *settings.GetHostedLoginTranslationRequest_System: + return getTranslationOutputMessage(sysTranslation, systemEtag) + case *settings.GetHostedLoginTranslationRequest_Instance: + levelID = authz.GetInstance(ctx).InstanceID() + resourceOwner = instance.AggregateType + case *settings.GetHostedLoginTranslationRequest_OrganizationId: + levelID = t.OrganizationId + resourceOwner = org.AggregateType + default: + return nil, zerrors.ThrowInvalidArgument(nil, "QUERY-YB6Sri", "Errors.Arguments.Level.Invalid") + } + + stmt, scan := prepareHostedLoginTranslationQuery() + + langORBaseLang := sq.Or{ + sq.Eq{hostedLoginTranslationColLocale.identifier(): lang.String()}, + sq.Eq{hostedLoginTranslationColLocale.identifier(): parentLang.String()}, + } + eq := sq.Eq{ + hostedLoginTranslationColInstanceID.identifier(): inst.InstanceID(), + hostedLoginTranslationColResourceOwner.identifier(): levelID, + hostedLoginTranslationColResourceOwnerType.identifier(): resourceOwner, + } + + query, args, err := stmt.Where(eq).Where(langORBaseLang).ToSql() + if err != nil { + logging.WithError(err).Error("unable to generate sql statement") + return nil, zerrors.ThrowInternal(err, "QUERY-ZgCMux", "Errors.Query.SQLStatement") + } + + var trs []*HostedLoginTranslation + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + trs, err = scan(rows) + return err + }, query, args...) + if err != nil { + logging.WithError(err).Error("failed to query translations") + return nil, zerrors.ThrowInternal(err, "QUERY-6k1zjx", "Errors.Internal") + } + + requestedTranslation, parentTranslation := &HostedLoginTranslation{}, &HostedLoginTranslation{} + for _, tr := range trs { + if tr == nil { + continue + } + + if tr.LevelType == resourceOwner { + requestedTranslation = tr + } else { + parentTranslation = tr + } + } + + if !req.GetIgnoreInheritance() { + + // There is no record for the requested level, set the upper level etag + if requestedTranslation.Etag == "" { + requestedTranslation.Etag = parentTranslation.Etag + } + + // Case where Level == ORGANIZATION -> Check if we have an instance level translation + // If so, merge it with the translations we have + if parentTranslation != nil && parentTranslation.LevelType == instance.AggregateType { + if err := mergo.Merge(&requestedTranslation.File, parentTranslation.File); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-pdgEJd", "Errors.Query.MergeTranslations") + } + } + + // The DB query returned no results, we have to set the system translation etag + if requestedTranslation.Etag == "" { + requestedTranslation.Etag = systemEtag + } + + // Merge the system translations + if err := mergo.Merge(&requestedTranslation.File, sysTranslation); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-HdprNF", "Errors.Query.MergeTranslations") + } + } + + return getTranslationOutputMessage(requestedTranslation.File, requestedTranslation.Etag) +} + +func getSystemTranslation(lang, instanceDefaultLang language.Tag) (map[string]any, string, error) { + translation, ok := defaultSystemTranslations[lang] + if !ok { + translation, ok = defaultSystemTranslations[instanceDefaultLang] + if !ok { + return nil, "", zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", lang) + } + } + + hash := md5.Sum(fmt.Append(nil, translation)) + + return translation, hex.EncodeToString(hash[:]), nil +} + +func prepareHostedLoginTranslationQuery() (sq.SelectBuilder, func(*sql.Rows) ([]*HostedLoginTranslation, error)) { + return sq.Select( + hostedLoginTranslationColFile.identifier(), + hostedLoginTranslationColResourceOwnerType.identifier(), + hostedLoginTranslationColEtag.identifier(), + ).From(hostedLoginTranslationTable.identifier()). + Limit(2). + PlaceholderFormat(sq.Dollar), + func(r *sql.Rows) ([]*HostedLoginTranslation, error) { + translations := make([]*HostedLoginTranslation, 0, 2) + for r.Next() { + var rawTranslation json.RawMessage + translation := &HostedLoginTranslation{} + err := r.Scan( + &rawTranslation, + &translation.LevelType, + &translation.Etag, + ) + if err != nil { + return nil, err + } + + if err := json.Unmarshal(rawTranslation, &translation.File); err != nil { + return nil, err + } + + translations = append(translations, translation) + } + + if err := r.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-oc7r7i", "Errors.Query.CloseRows") + } + + return translations, nil + } +} + +func getTranslationOutputMessage(translation map[string]any, etag string) (*settings.GetHostedLoginTranslationResponse, error) { + protoTranslation, err := structpb.NewStruct(translation) + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-70ppPp", "Errors.Protobuf.ConvertToStruct") + } + + return &settings.GetHostedLoginTranslationResponse{ + Translations: protoTranslation, + Etag: etag, + }, nil +} diff --git a/internal/query/hosted_login_translation_test.go b/internal/query/hosted_login_translation_test.go new file mode 100644 index 0000000000..0e9f511002 --- /dev/null +++ b/internal/query/hosted_login_translation_test.go @@ -0,0 +1,337 @@ +package query + +import ( + "crypto/md5" + "database/sql" + "database/sql/driver" + "encoding/hex" + "encoding/json" + "fmt" + "maps" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/protobuf/runtime/protoimpl" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/database/mock" + "github.com/zitadel/zitadel/internal/zerrors" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2" +) + +func TestGetSystemTranslation(t *testing.T) { + okTranslation := defaultLoginTranslations + + parsedOKTranslation := map[string]map[string]any{} + require.Nil(t, json.Unmarshal(okTranslation, &parsedOKTranslation)) + + hashOK := md5.Sum(fmt.Append(nil, parsedOKTranslation["de"])) + + tt := []struct { + testName string + + inputLanguage language.Tag + inputInstanceLanguage language.Tag + systemTranslationToSet []byte + + expectedLanguage map[string]any + expectedEtag string + expectedError error + }{ + { + testName: "when neither input language nor system default language have translation should return not found error", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("ro"), + inputInstanceLanguage: language.MustParse("fr"), + + expectedError: zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", "ro"), + }, + { + testName: "when input language has no translation should fallback onto instance default", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("ro"), + inputInstanceLanguage: language.MustParse("de"), + + expectedLanguage: parsedOKTranslation["de"], + expectedEtag: hex.EncodeToString(hashOK[:]), + }, + { + testName: "when input language has translation should return it", + systemTranslationToSet: okTranslation, + inputLanguage: language.MustParse("de"), + inputInstanceLanguage: language.MustParse("en"), + + expectedLanguage: parsedOKTranslation["de"], + expectedEtag: hex.EncodeToString(hashOK[:]), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + defaultLoginTranslations = tc.systemTranslationToSet + + // When + translation, etag, err := getSystemTranslation(tc.inputLanguage, tc.inputInstanceLanguage) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedLanguage, translation) + assert.Equal(t, tc.expectedEtag, etag) + }) + } +} + +func TestGetTranslationOutput(t *testing.T) { + t.Parallel() + + validMap := map[string]any{"loginHeader": "A login header"} + protoMap, err := structpb.NewStruct(validMap) + require.NoError(t, err) + + hash := md5.Sum(fmt.Append(nil, validMap)) + encodedHash := hex.EncodeToString(hash[:]) + + tt := []struct { + testName string + inputTranslation map[string]any + expectedError error + expectedResponse *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when unparsable map should return internal error", + inputTranslation: map[string]any{"\xc5z": "something"}, + expectedError: zerrors.ThrowInternal(protoimpl.X.NewError("invalid UTF-8 in string: %q", "\xc5z"), "QUERY-70ppPp", "Errors.Protobuf.ConvertToStruct"), + }, + { + testName: "when input translation is valid should return expected response message", + inputTranslation: validMap, + expectedResponse: &settings.GetHostedLoginTranslationResponse{ + Translations: protoMap, + Etag: hex.EncodeToString(hash[:]), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := getTranslationOutputMessage(tc.inputTranslation, encodedHash) + + // Verify + require.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestGetHostedLoginTranslation(t *testing.T) { + query := `SELECT projections.hosted_login_translations.file, projections.hosted_login_translations.aggregate_type, projections.hosted_login_translations.etag + FROM projections.hosted_login_translations + WHERE projections.hosted_login_translations.aggregate_id = $1 + AND projections.hosted_login_translations.aggregate_type = $2 + AND projections.hosted_login_translations.instance_id = $3 + AND (projections.hosted_login_translations.locale = $4 OR projections.hosted_login_translations.locale = $5) + LIMIT 2` + okTranslation := defaultLoginTranslations + + parsedOKTranslation := map[string]map[string]any{} + require.NoError(t, json.Unmarshal(okTranslation, &parsedOKTranslation)) + + protoDefaultTranslation, err := structpb.NewStruct(parsedOKTranslation["en"]) + require.Nil(t, err) + + defaultWithDBTranslations := maps.Clone(parsedOKTranslation["en"]) + defaultWithDBTranslations["test"] = "translation" + defaultWithDBTranslations["test2"] = "translation2" + protoDefaultWithDBTranslation, err := structpb.NewStruct(defaultWithDBTranslations) + require.NoError(t, err) + + nilProtoDefaultMap, err := structpb.NewStruct(nil) + require.NoError(t, err) + + hashDefaultTranslations := md5.Sum(fmt.Append(nil, parsedOKTranslation["en"])) + + tt := []struct { + testName string + + defaultInstanceLanguage language.Tag + sqlExpectations []mock.Expectation + + inputRequest *settings.GetHostedLoginTranslationRequest + + expectedError error + expectedResult *settings.GetHostedLoginTranslationResponse + }{ + { + testName: "when input language is invalid should return invalid argument error", + + inputRequest: &settings.GetHostedLoginTranslationRequest{}, + + expectedError: zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when input language is root should return invalid argument error", + + defaultInstanceLanguage: language.English, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "root", + }, + + expectedError: zerrors.ThrowInvalidArgument(nil, "QUERY-rZLAGi", "Errors.Arguments.Locale.Invalid"), + }, + { + testName: "when no system translation is available should return not found error", + + defaultInstanceLanguage: language.Romanian, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "ro-RO", + }, + + expectedError: zerrors.ThrowNotFoundf(nil, "QUERY-6gb5QR", "Errors.Query.HostedLoginTranslationNotFound-%s", "ro"), + }, + { + testName: "when requesting system translation should return it", + + defaultInstanceLanguage: language.English, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_System{}, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Translations: protoDefaultTranslation, + Etag: hex.EncodeToString(hashDefaultTranslations[:]), + }, + }, + { + testName: "when querying DB fails should return internal error", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryErr(sql.ErrConnDone), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedError: zerrors.ThrowInternal(sql.ErrConnDone, "QUERY-6k1zjx", "Errors.Internal"), + }, + { + testName: "when querying DB returns no result should return system translations", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{}, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Translations: protoDefaultTranslation, + Etag: hex.EncodeToString(hashDefaultTranslations[:]), + }, + }, + { + testName: "when querying DB returns no result and inheritance disabled should return empty result", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{}, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + IgnoreInheritance: true, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Etag: "", + Translations: nilProtoDefaultMap, + }, + }, + { + testName: "when querying DB returns records should return merged result", + + defaultInstanceLanguage: language.English, + sqlExpectations: []mock.Expectation{ + mock.ExpectQuery( + query, + mock.WithQueryArgs("123", "org", "instance-id", "en-US", "en"), + mock.WithQueryResult( + []string{"file", "aggregate_type", "etag"}, + [][]driver.Value{ + {[]byte(`{"test": "translation"}`), "org", "etag-org"}, + {[]byte(`{"test2": "translation2"}`), "instance", "etag-instance"}, + }, + ), + ), + }, + inputRequest: &settings.GetHostedLoginTranslationRequest{ + Locale: "en-US", + Level: &settings.GetHostedLoginTranslationRequest_OrganizationId{ + OrganizationId: "123", + }, + }, + + expectedResult: &settings.GetHostedLoginTranslationResponse{ + Etag: "etag-org", + Translations: protoDefaultWithDBTranslation, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + db := &database.DB{DB: mock.NewSQLMock(t, tc.sqlExpectations...).DB} + querier := Queries{client: db} + + ctx := authz.NewMockContext("instance-id", "org-id", "user-id", authz.WithMockDefaultLanguage(tc.defaultInstanceLanguage)) + + // When + res, err := querier.GetHostedLoginTranslation(ctx, tc.inputRequest) + + // Verify + require.Equal(t, tc.expectedError, err) + + if tc.expectedError == nil { + assert.Equal(t, tc.expectedResult.GetEtag(), res.GetEtag()) + assert.Equal(t, tc.expectedResult.GetTranslations().GetFields(), res.GetTranslations().GetFields()) + } + }) + } +} diff --git a/internal/query/projection/hosted_login_translation.go b/internal/query/projection/hosted_login_translation.go new file mode 100644 index 0000000000..865d3738b9 --- /dev/null +++ b/internal/query/projection/hosted_login_translation.go @@ -0,0 +1,144 @@ +package projection + +import ( + "context" + "crypto/md5" + "encoding/hex" + "fmt" + + "github.com/zitadel/zitadel/internal/eventstore" + old_handler "github.com/zitadel/zitadel/internal/eventstore/handler" + "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationTable = "projections.hosted_login_translations" + + HostedLoginTranslationInstanceIDCol = "instance_id" + HostedLoginTranslationCreationDateCol = "creation_date" + HostedLoginTranslationChangeDateCol = "change_date" + HostedLoginTranslationAggregateIDCol = "aggregate_id" + HostedLoginTranslationAggregateTypeCol = "aggregate_type" + HostedLoginTranslationSequenceCol = "sequence" + HostedLoginTranslationLocaleCol = "locale" + HostedLoginTranslationFileCol = "file" + HostedLoginTranslationEtagCol = "etag" +) + +type hostedLoginTranslationProjection struct{} + +func newHostedLoginTranslationProjection(ctx context.Context, config handler.Config) *handler.Handler { + return handler.NewHandler(ctx, &config, new(hostedLoginTranslationProjection)) +} + +// Init implements [handler.initializer] +func (p *hostedLoginTranslationProjection) Init() *old_handler.Check { + return handler.NewTableCheck( + handler.NewTable([]*handler.InitColumn{ + handler.NewColumn(HostedLoginTranslationInstanceIDCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationCreationDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(HostedLoginTranslationChangeDateCol, handler.ColumnTypeTimestamp), + handler.NewColumn(HostedLoginTranslationAggregateIDCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationAggregateTypeCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationSequenceCol, handler.ColumnTypeInt64), + handler.NewColumn(HostedLoginTranslationLocaleCol, handler.ColumnTypeText), + handler.NewColumn(HostedLoginTranslationFileCol, handler.ColumnTypeJSONB), + handler.NewColumn(HostedLoginTranslationEtagCol, handler.ColumnTypeText), + }, + handler.NewPrimaryKey( + HostedLoginTranslationInstanceIDCol, + HostedLoginTranslationAggregateIDCol, + HostedLoginTranslationAggregateTypeCol, + HostedLoginTranslationLocaleCol, + ), + ), + ) +} + +func (hltp *hostedLoginTranslationProjection) Name() string { + return HostedLoginTranslationTable +} + +func (hltp *hostedLoginTranslationProjection) Reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: org.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: org.HostedLoginTranslationSet, + Reduce: hltp.reduceSet, + }, + }, + }, + { + Aggregate: instance.AggregateType, + EventReducers: []handler.EventReducer{ + { + Event: instance.HostedLoginTranslationSet, + Reduce: hltp.reduceSet, + }, + }, + }, + } +} + +func (hltp *hostedLoginTranslationProjection) reduceSet(e eventstore.Event) (*handler.Statement, error) { + + switch e := e.(type) { + case *org.HostedLoginTranslationSetEvent: + orgEvent := *e + return handler.NewUpsertStatement( + &orgEvent, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, nil), + handler.NewCol(HostedLoginTranslationLocaleCol, nil), + }, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, orgEvent.Aggregate().InstanceID), + handler.NewCol(HostedLoginTranslationAggregateIDCol, orgEvent.Aggregate().ID), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, orgEvent.Aggregate().Type), + handler.NewCol(HostedLoginTranslationCreationDateCol, handler.OnlySetValueOnInsert(HostedLoginTranslationTable, orgEvent.CreationDate())), + handler.NewCol(HostedLoginTranslationChangeDateCol, orgEvent.CreationDate()), + handler.NewCol(HostedLoginTranslationSequenceCol, orgEvent.Sequence()), + handler.NewCol(HostedLoginTranslationLocaleCol, orgEvent.Language), + handler.NewCol(HostedLoginTranslationFileCol, orgEvent.Translation), + handler.NewCol(HostedLoginTranslationEtagCol, hltp.computeEtag(orgEvent.Translation)), + }, + ), nil + case *instance.HostedLoginTranslationSetEvent: + instanceEvent := *e + return handler.NewUpsertStatement( + &instanceEvent, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateIDCol, nil), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, nil), + handler.NewCol(HostedLoginTranslationLocaleCol, nil), + }, + []handler.Column{ + handler.NewCol(HostedLoginTranslationInstanceIDCol, instanceEvent.Aggregate().InstanceID), + handler.NewCol(HostedLoginTranslationAggregateIDCol, instanceEvent.Aggregate().ID), + handler.NewCol(HostedLoginTranslationAggregateTypeCol, instanceEvent.Aggregate().Type), + handler.NewCol(HostedLoginTranslationCreationDateCol, handler.OnlySetValueOnInsert(HostedLoginTranslationTable, instanceEvent.CreationDate())), + handler.NewCol(HostedLoginTranslationChangeDateCol, instanceEvent.CreationDate()), + handler.NewCol(HostedLoginTranslationSequenceCol, instanceEvent.Sequence()), + handler.NewCol(HostedLoginTranslationLocaleCol, instanceEvent.Language), + handler.NewCol(HostedLoginTranslationFileCol, instanceEvent.Translation), + handler.NewCol(HostedLoginTranslationEtagCol, hltp.computeEtag(instanceEvent.Translation)), + }, + ), nil + default: + return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-AZshaa", "reduce.wrong.event.type %v", []eventstore.EventType{org.HostedLoginTranslationSet}) + } + +} + +func (hltp *hostedLoginTranslationProjection) computeEtag(translation map[string]any) string { + hash := md5.Sum(fmt.Append(nil, translation)) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 77a28ac79a..5ad62380ea 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -86,6 +86,7 @@ var ( UserSchemaProjection *handler.Handler WebKeyProjection *handler.Handler DebugEventsProjection *handler.Handler + HostedLoginTranslationProjection *handler.Handler ProjectGrantFields *handler.FieldHandler OrgDomainVerifiedFields *handler.FieldHandler @@ -179,6 +180,7 @@ func Create(ctx context.Context, sqlClient *database.DB, es handler.EventStore, UserSchemaProjection = newUserSchemaProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["user_schemas"])) WebKeyProjection = newWebKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["web_keys"])) DebugEventsProjection = newDebugEventsProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["debug_events"])) + HostedLoginTranslationProjection = newHostedLoginTranslationProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["hosted_login_translation"])) ProjectGrantFields = newFillProjectGrantFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsProjectGrant])) OrgDomainVerifiedFields = newFillOrgDomainVerifiedFields(applyCustomConfig(projectionConfig, config.Customizations[fieldsOrgDomainVerified])) @@ -357,5 +359,6 @@ func newProjectionsList() { UserSchemaProjection, WebKeyProjection, DebugEventsProjection, + HostedLoginTranslationProjection, } } diff --git a/internal/query/v2-default.json b/internal/query/v2-default.json new file mode 100644 index 0000000000..c86396ef34 --- /dev/null +++ b/internal/query/v2-default.json @@ -0,0 +1,1557 @@ +{ + "de":{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + } + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "success": "Erfolgreich verifiziert", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } + }, + "en":{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + } + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "success": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } + }, + "es":{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + } + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "success": "¡Verificación exitosa!", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } + }, + "it":{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + } + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "success": "Verifica effettuata con successo!", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } + + }, + "pl":{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + } + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "success": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } + }, + "ru":{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + } + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "success": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } + }, + "zh":{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + } + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "success": "用户验证成功。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } + } +} \ No newline at end of file diff --git a/internal/repository/instance/eventstore.go b/internal/repository/instance/eventstore.go index 68621597a8..b8089152bb 100644 --- a/internal/repository/instance/eventstore.go +++ b/internal/repository/instance/eventstore.go @@ -130,4 +130,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, TrustedDomainAddedEventType, eventstore.GenericEventMapper[TrustedDomainAddedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, TrustedDomainRemovedEventType, eventstore.GenericEventMapper[TrustedDomainRemovedEvent]) + eventstore.RegisterFilterEventMapper(AggregateType, HostedLoginTranslationSet, HostedLoginTranslationSetEventMapper) } diff --git a/internal/repository/instance/hosted_login_translation.go b/internal/repository/instance/hosted_login_translation.go new file mode 100644 index 0000000000..05380521fc --- /dev/null +++ b/internal/repository/instance/hosted_login_translation.go @@ -0,0 +1,55 @@ +package instance + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationSet = instanceEventTypePrefix + "hosted_login_translation.set" +) + +type HostedLoginTranslationSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Translation map[string]any `json:"translation,omitempty"` + Language language.Tag `json:"language,omitempty"` + Level string `json:"level,omitempty"` +} + +func NewHostedLoginTranslationSetEvent(ctx context.Context, aggregate *eventstore.Aggregate, translation map[string]any, language language.Tag) *HostedLoginTranslationSetEvent { + return &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush(ctx, aggregate, HostedLoginTranslationSet), + Translation: translation, + Language: language, + Level: string(aggregate.Type), + } +} + +func (e *HostedLoginTranslationSetEvent) Payload() any { + return e +} + +func (e *HostedLoginTranslationSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HostedLoginTranslationSetEvent) Fields() []*eventstore.FieldOperation { + return nil +} + +func HostedLoginTranslationSetEventMapper(event eventstore.Event) (eventstore.Event, error) { + translationSet := &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(translationSet) + if err != nil { + return nil, zerrors.ThrowInternal(err, "INST-lOxtJJ", "unable to unmarshal hosted login translation set event") + } + + return translationSet, nil +} diff --git a/internal/repository/org/eventstore.go b/internal/repository/org/eventstore.go index d1efa75dfc..289bbbc608 100644 --- a/internal/repository/org/eventstore.go +++ b/internal/repository/org/eventstore.go @@ -114,4 +114,5 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyAddedEventType, NotificationPolicyAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyChangedEventType, NotificationPolicyChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, NotificationPolicyRemovedEventType, NotificationPolicyRemovedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, HostedLoginTranslationSet, HostedLoginTranslationSetEventMapper) } diff --git a/internal/repository/org/hosted_login_translation.go b/internal/repository/org/hosted_login_translation.go new file mode 100644 index 0000000000..e07bdc1e3b --- /dev/null +++ b/internal/repository/org/hosted_login_translation.go @@ -0,0 +1,55 @@ +package org + +import ( + "context" + + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const ( + HostedLoginTranslationSet = orgEventTypePrefix + "hosted_login_translation.set" +) + +type HostedLoginTranslationSetEvent struct { + eventstore.BaseEvent `json:"-"` + + Translation map[string]any `json:"translation,omitempty"` + Language language.Tag `json:"language,omitempty"` + Level string `json:"level,omitempty"` +} + +func NewHostedLoginTranslationSetEvent(ctx context.Context, aggregate *eventstore.Aggregate, translation map[string]any, language language.Tag) *HostedLoginTranslationSetEvent { + return &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.NewBaseEventForPush(ctx, aggregate, HostedLoginTranslationSet), + Translation: translation, + Language: language, + Level: string(aggregate.Type), + } +} + +func (e *HostedLoginTranslationSetEvent) Payload() any { + return e +} + +func (e *HostedLoginTranslationSetEvent) UniqueConstraints() []*eventstore.UniqueConstraint { + return nil +} + +func (e *HostedLoginTranslationSetEvent) Fields() []*eventstore.FieldOperation { + return nil +} + +func HostedLoginTranslationSetEventMapper(event eventstore.Event) (eventstore.Event, error) { + translationSet := &HostedLoginTranslationSetEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := event.Unmarshal(translationSet) + if err != nil { + return nil, zerrors.ThrowInternal(err, "ORG-BH82Eb", "unable to unmarshal hosted login translation set event") + } + + return translationSet, nil +} diff --git a/proto/zitadel/settings/v2/settings.proto b/proto/zitadel/settings/v2/settings.proto index b3ca5b5ca5..c797d27965 100644 --- a/proto/zitadel/settings/v2/settings.proto +++ b/proto/zitadel/settings/v2/settings.proto @@ -10,4 +10,4 @@ enum ResourceOwnerType { RESOURCE_OWNER_TYPE_UNSPECIFIED = 0; RESOURCE_OWNER_TYPE_INSTANCE = 1; RESOURCE_OWNER_TYPE_ORG = 2; -} +} \ No newline at end of file diff --git a/proto/zitadel/settings/v2/settings_service.proto b/proto/zitadel/settings/v2/settings_service.proto index 7f71e08da4..0a1f13e7e7 100644 --- a/proto/zitadel/settings/v2/settings_service.proto +++ b/proto/zitadel/settings/v2/settings_service.proto @@ -15,6 +15,8 @@ import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; +import "google/protobuf/struct.proto"; +import "zitadel/settings/v2/settings.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/settings/v2;settings"; @@ -362,6 +364,69 @@ service SettingsService { description: "Set the security settings of the ZITADEL instance." }; } + + // Get Hosted Login Translation + // + // Returns the translations in the requested locale for the hosted login. + // The translations returned are based on the input level specified (system, instance or organization). + // + // If the requested level doesn't contain all translations, and ignore_inheritance is set to false, + // a merging process fallbacks onto the higher levels ensuring all keys in the file have a translation, + // which could be in the default language if the one of the locale is missing on all levels. + // + // The etag returned in the response represents the hash of the translations as they are stored on DB + // and its reliable only if ignore_inheritance = true. + // + // Required permissions: + // - `iam.policy.read` + rpc GetHostedLoginTranslation(GetHostedLoginTranslationRequest) returns (GetHostedLoginTranslationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The localized translations."; + } + }; + }; + + option (google.api.http) = { + get: "/v2/settings/hosted_login_translation" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.policy.read" + } + }; + } + + // Set Hosted Login Translation + // + // Sets the input translations at the specified level (instance or organization) for the input language. + // + // Required permissions: + // - `iam.policy.write` + rpc SetHostedLoginTranslation(SetHostedLoginTranslationRequest) returns (SetHostedLoginTranslationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The translations was successfully set."; + } + }; + }; + + option (google.api.http) = { + put: "/v2/settings/hosted_login_translation"; + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "iam.policy.write" + } + }; + } } message GetLoginSettingsRequest { @@ -480,4 +545,76 @@ message SetSecuritySettingsRequest{ message SetSecuritySettingsResponse{ zitadel.object.v2.Details details = 1; +} + +message GetHostedLoginTranslationRequest { + oneof level { + bool system = 1 [(validate.rules).bool = {const: true}]; + bool instance = 2 [(validate.rules).bool = {const: true}]; + string organization_id = 3; + } + + string locale = 4 [ + (validate.rules).string = {min_len: 2}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 2; + example: "\"fr-FR\""; + } + ]; + + // if set to true, higher levels are ignored, if false higher levels are merged into the file + bool ignore_inheritance = 5; +} + +message GetHostedLoginTranslationResponse { + // hash of the payload + string etag = 1 [ + (validate.rules).string = {min_len: 32, max_len: 32}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 32; + max_length: 32; + example: "\"42a1ba123e6ea6f0c93e286ed97c7018\""; + } + ]; + + google.protobuf.Struct translations = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations contains the translations in the request language."; + } + ]; +} + +message SetHostedLoginTranslationRequest { + oneof level { + bool instance = 1 [(validate.rules).bool = {const: true}]; + string organization_id = 2; + } + + string locale = 3 [ + (validate.rules).string = {min_len: 2}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 2; + example: "\"fr-FR\""; + } + ]; + + google.protobuf.Struct translations = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"common\":{\"back\":\"Indietro\"},\"accounts\":{\"title\":\"Account\",\"description\":\"Seleziona l'account che desideri utilizzare.\",\"addAnother\":\"Aggiungi un altro account\",\"noResults\":\"Nessun account trovato\"}}"; + description: "Translations should contain the translations in the specified locale."; + } + ]; +} + +message SetHostedLoginTranslationResponse { + // hash of the saved translation. Valid only when ignore_inheritance = true + string etag = 1 [ + (validate.rules).string = {min_len: 32, max_len: 32}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 32; + max_length: 32; + example: "\"42a1ba123e6ea6f0c93e286ed97c7018\""; + } + ]; } \ No newline at end of file From 3a4298c1794ad61c4fa3d3d186aa4235b8742424 Mon Sep 17 00:00:00 2001 From: Trong Huu Nguyen Date: Thu, 19 Jun 2025 11:42:44 +0200 Subject: [PATCH 100/123] fix(scim): add type attribute to ScimEmail (#9690) # Which Problems Are Solved - SCIM PATCH operations for users from Entra ID for the `emails` attribute fails due to missing `type` subattribute # How the Problems Are Solved - Adds the `type` attribute to the `ScimUser` struct and sets the default value to `"work"` in the `mapWriteModelToScimUser()` method. # Additional Changes # Additional Context The SCIM handlers for POST and PUT ignore multiple emails and only uses the primary email for a given user, or falls back to the first email if none are marked as primary. PATCH operations however, will attempt to resolve the provided filter in `operations[].path`. Some services, such as Entra ID, only support patching emails by filtering for `emails[type eq "(work|home|other)"].value`, which fails with Zitadel as the ScimUser struct (and thus the generated schema) doesn't include the `type` field. This commit adds the `type` field to work around this issue, while still preserving compatibility with filters such as `emails[primary eq true].value`. - https://discord.com/channels/927474939156643850/927866013545025566/1356556668527448191 --------- Co-authored-by: Christer Edvartsen Co-authored-by: Thomas Siegfried Krampl --- ...vice_provider_config_expected_schemas.json | 11 +++++ ..._provider_config_expected_user_schema.json | 11 +++++ ..._replace_test_minimal_with_email_type.json | 17 ++++++++ .../integration_test/users_create_test.go | 1 + .../scim/integration_test/users_get_test.go | 1 + .../integration_test/users_replace_test.go | 40 ++++++++++++++++++- .../integration_test/users_update_test.go | 9 +++++ internal/api/scim/metadata/metadata.go | 3 ++ internal/api/scim/resources/user.go | 1 + internal/api/scim/resources/user_mapping.go | 4 ++ internal/api/scim/resources/user_metadata.go | 5 ++- .../api/scim/resources/user_patch_test.go | 34 ++++++++++++++++ 12 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json index bc87b8e2e1..2751c85a79 100644 --- a/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_schemas.json @@ -233,6 +233,17 @@ "mutability": "readWrite", "returned": "always", "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" } ], "multiValued": true, diff --git a/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json b/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json index a199fe1465..35d0e356b3 100644 --- a/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json +++ b/internal/api/scim/integration_test/testdata/service_provider_config_expected_user_schema.json @@ -225,6 +225,17 @@ "mutability": "readWrite", "returned": "always", "uniqueness": "none" + }, + { + "name": "type", + "description": "For details see RFC7643", + "type": "string", + "multiValued": false, + "required": false, + "caseExact": true, + "mutability": "readWrite", + "returned": "always", + "uniqueness": "none" } ], "multiValued": true, diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json new file mode 100644 index 0000000000..b7e8d87590 --- /dev/null +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json @@ -0,0 +1,17 @@ +{ + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User" + ], + "userName": "acmeUser1-minimal-replaced", + "name": { + "familyName": "Ross-replaced", + "givenName": "Bethany-replaced" + }, + "emails": [ + { + "value": "user1-minimal-replaced@example.com", + "primary": true, + "type": "work" + } + ] +} \ No newline at end of file diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index 8b6986666c..35d5297878 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -391,6 +391,7 @@ func TestCreateUser_metadata(t *testing.T) { test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US") test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`) test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`) + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", `[{"value":"bjensen@example.com","primary":true,"type":"work"},{"value":"babs@jensen.org","primary":false,"type":"home"}]`) }, retryDuration, tick) } diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go index 0106e47ca2..8a1bab6c93 100644 --- a/internal/api/scim/integration_test/users_get_test.go +++ b/internal/api/scim/integration_test/users_get_test.go @@ -115,6 +115,7 @@ func TestGetUser(t *testing.T) { { Value: "bjensen@example.com", Primary: true, + Type: "work", }, }, PhoneNumbers: []*resources.ScimPhoneNumber{ diff --git a/internal/api/scim/integration_test/users_replace_test.go b/internal/api/scim/integration_test/users_replace_test.go index 770ed06959..1c99592b01 100644 --- a/internal/api/scim/integration_test/users_replace_test.go +++ b/internal/api/scim/integration_test/users_replace_test.go @@ -27,6 +27,9 @@ var ( //go:embed testdata/users_replace_test_minimal_with_external_id.json minimalUserWithExternalIDJson []byte + //go:embed testdata/users_replace_test_minimal_with_email_type.json + minimalUserWithEmailTypeReplaceJson []byte + //go:embed testdata/users_replace_test_minimal.json minimalUserReplaceJson []byte @@ -303,7 +306,42 @@ func TestReplaceUser_removeOldMetadata(t *testing.T) { Id: createdUser.ID, }) require.NoError(tt, err) - require.Equal(tt, 0, len(md.Result)) + require.Equal(tt, 1, len(md.Result)) + + mdMap := make(map[string]string) + for i := range md.Result { + mdMap[md.Result[i].Key] = string(md.Result[i].Value) + } + + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1@example.com\",\"primary\":true}]") + }, retryDuration, tick) + + _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) + require.NoError(t, err) +} + +func TestReplaceUser_emailType(t *testing.T) { + // ensure old metadata is removed correctly + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + require.NoError(t, err) + + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithEmailTypeReplaceJson) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + Id: createdUser.ID, + }) + require.NoError(tt, err) + require.Equal(tt, 1, len(md.Result)) + + mdMap := make(map[string]string) + for i := range md.Result { + mdMap[md.Result[i].Key] = string(md.Result[i].Value) + } + + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1-minimal-replaced@example.com\",\"primary\":true,\"type\":\"work\"}]") }, retryDuration, tick) _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) diff --git a/internal/api/scim/integration_test/users_update_test.go b/internal/api/scim/integration_test/users_update_test.go index 2fe129291a..77e55bac60 100644 --- a/internal/api/scim/integration_test/users_update_test.go +++ b/internal/api/scim/integration_test/users_update_test.go @@ -137,9 +137,18 @@ func TestUpdateUser(t *testing.T) { NickName: "", ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), Emails: []*resources.ScimEmail{ + { + Value: "bjensen@example.com", + Type: "work", + }, + { + Value: "babs@jensen.org", + Type: "home", + }, { Value: "babs@example.com", Primary: true, + Type: "home", }, }, Addresses: []*resources.ScimAddress{ diff --git a/internal/api/scim/metadata/metadata.go b/internal/api/scim/metadata/metadata.go index 66a0a2483c..28e42290d1 100644 --- a/internal/api/scim/metadata/metadata.go +++ b/internal/api/scim/metadata/metadata.go @@ -30,6 +30,7 @@ const ( KeyAddresses Key = KeyPrefix + "addresses" KeyEntitlements Key = KeyPrefix + "entitlements" KeyRoles Key = KeyPrefix + "roles" + KeyEmails Key = KeyPrefix + "emails" ) var ( @@ -47,6 +48,7 @@ var ( KeyAddresses, KeyEntitlements, KeyRoles, + KeyEmails, } AttributePathToMetadataKeys = map[string][]Key{ @@ -64,6 +66,7 @@ var ( "addresses": {KeyAddresses}, "entitlements": {KeyEntitlements}, "roles": {KeyRoles}, + "emails": {KeyEmails}, } ) diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 13baed5d51..6506ae35c7 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -90,6 +90,7 @@ type ScimIms struct { type ScimEmail struct { Value string `json:"value" scim:"required"` Primary bool `json:"primary"` + Type string `json:"type,omitempty"` } type ScimPhoneNumber struct { diff --git a/internal/api/scim/resources/user_mapping.go b/internal/api/scim/resources/user_mapping.go index 171af87238..260e50846a 100644 --- a/internal/api/scim/resources/user_mapping.go +++ b/internal/api/scim/resources/user_mapping.go @@ -382,6 +382,10 @@ func (h *UsersHandler) mapAndValidateMetadata(ctx context.Context, user *ScimUse if err := extractJsonMetadata(ctx, md, metadata.KeyRoles, &user.Roles); err != nil { logging.OnError(err).Warn("Could not deserialize scim roles metadata") } + + if err := extractJsonMetadata(ctx, md, metadata.KeyEmails, &user.Emails); err != nil { + logging.OnError(err).Warn("Could not deserialize scim emails metadata") + } } func (h *UsersHandler) buildResourceForQuery(ctx context.Context, user *query.User) *schemas.Resource { diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index 69d85e40e5..3e018507fe 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -129,7 +129,8 @@ func getValueForMetadataKey(user *ScimUser, key metadata.Key) ([]byte, error) { metadata.KeyAddresses, metadata.KeyEntitlements, metadata.KeyIms, - metadata.KeyPhotos: + metadata.KeyPhotos, + metadata.KeyEmails: val, err := json.Marshal(value) if err != nil { return nil, err @@ -223,6 +224,8 @@ func getRawValueForMetadataKey(user *ScimUser, key metadata.Key) interface{} { return user.Locale case metadata.KeyTimezone: return user.Timezone + case metadata.KeyEmails: + return user.Emails case metadata.KeyProvisioningDomain: break } diff --git a/internal/api/scim/resources/user_patch_test.go b/internal/api/scim/resources/user_patch_test.go index ff3fc720bf..0c8aadc388 100644 --- a/internal/api/scim/resources/user_patch_test.go +++ b/internal/api/scim/resources/user_patch_test.go @@ -685,6 +685,39 @@ func TestOperationCollection_Apply(t *testing.T) { }, wantErr: true, }, + { + name: "replace filter complex subattribute multiple emails primary value", + op: &patch.Operation{ + Operation: patch.OperationTypeReplace, + Path: test.Must(filter.ParsePath(`emails[primary eq true].value`)), + Value: json.RawMessage(`"jeanie.rebecca.pendleton@example.com"`), + }, + want: &ScimUser{ + Emails: []*ScimEmail{ + { + Value: "jeanie.rebecca.pendleton@example.com", + Primary: true, + }, + }, + }, + }, + { + name: "replace filter complex subattribute multiple emails type value", + op: &patch.Operation{ + Operation: patch.OperationTypeReplace, + Path: test.Must(filter.ParsePath(`emails[type eq "work"].value`)), + Value: json.RawMessage(`"jeanie.rebecca.pendleton@example.com"`), + }, + want: &ScimUser{ + Emails: []*ScimEmail{ + { + Value: "jeanie.rebecca.pendleton@example.com", + Primary: true, + Type: "work", + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -711,6 +744,7 @@ func TestOperationCollection_Apply(t *testing.T) { { Value: "jeanie.pendleton@example.com", Primary: true, + Type: "work", }, }, PhoneNumbers: []*ScimPhoneNumber{ From fa9de9a0f123269cc257b849cec03b4ab316c133 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Tue, 24 Jun 2025 12:41:41 +0300 Subject: [PATCH 101/123] feat: generate webkeys setup step (#10105) # Which Problems Are Solved We are preparing to roll-out and stabilize webkeys in the next version of Zitadel. Before removing legacy signing-key code, we must ensure all existing instances have their webkeys generated. # How the Problems Are Solved Add a setup step which generate 2 webkeys for each existing instance that didn't have webkeys yet. # Additional Changes Return an error from the config type-switch, when the type is unknown. # Additional Context - Part 1/2 of https://github.com/zitadel/zitadel/issues/10029 - Should be back-ported to v3 --- cmd/setup/59.go | 54 ++++++++++++++++++++++++++++++++++++++ cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 ++ internal/crypto/web_key.go | 3 +++ 4 files changed, 60 insertions(+) create mode 100644 cmd/setup/59.go diff --git a/cmd/setup/59.go b/cmd/setup/59.go new file mode 100644 index 0000000000..530937d1a5 --- /dev/null +++ b/cmd/setup/59.go @@ -0,0 +1,54 @@ +package setup + +import ( + "context" + "fmt" + + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" +) + +type SetupWebkeys struct { + eventstore *eventstore.Eventstore + commands *command.Commands +} + +func (mig *SetupWebkeys) Execute(ctx context.Context, _ eventstore.Event) error { + instances, err := mig.eventstore.InstanceIDs( + ctx, + eventstore.NewSearchQueryBuilder(eventstore.ColumnsInstanceIDs). + OrderDesc(). + AddQuery(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceAddedEventType). + Builder().ExcludeAggregateIDs(). + AggregateTypes(instance.AggregateType). + EventTypes(instance.InstanceRemovedEventType). + Builder(), + ) + if err != nil { + return fmt.Errorf("%s get instance IDs: %w", mig, err) + } + conf := &crypto.WebKeyRSAConfig{ + Bits: crypto.RSABits2048, + Hasher: crypto.RSAHasherSHA256, + } + + for _, instance := range instances { + ctx := authz.WithInstanceID(ctx, instance) + logging.Info("prepare initial webkeys for instance", "instance_id", instance, "migration", mig) + if err := mig.commands.GenerateInitialWebKeys(ctx, conf); err != nil { + return fmt.Errorf("%s generate initial webkeys: %w", mig, err) + } + } + return nil +} + +func (mig *SetupWebkeys) String() string { + return "59_setup_webkeys" +} diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 0c3f726902..7385cc7652 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -155,6 +155,7 @@ type Steps struct { s56IDPTemplate6SAMLFederatedLogout *IDPTemplate6SAMLFederatedLogout s57CreateResourceCounts *CreateResourceCounts s58ReplaceLoginNames3View *ReplaceLoginNames3View + s59SetupWebkeys *SetupWebkeys } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index 8ee8d7fc68..dd23c320c7 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -272,6 +272,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) } commands, _, _, _ := startCommandsQueries(ctx, eventstoreClient, eventstoreV4, dbClient, masterKey, config) + steps.s59SetupWebkeys = &SetupWebkeys{eventstore: eventstoreClient, commands: commands} repeatableSteps := []migration.RepeatableMigration{ &externalConfigChange{ @@ -321,6 +322,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s42Apps7OIDCConfigsLoginVersion, steps.s43CreateFieldsDomainIndex, steps.s48Apps7SAMLConfigsLoginVersion, + steps.s59SetupWebkeys, // this step needs commands. } { setupErr = executeMigration(ctx, eventstoreClient, step, "migration failed") if setupErr != nil { diff --git a/internal/crypto/web_key.go b/internal/crypto/web_key.go index c769cb1213..286305259b 100644 --- a/internal/crypto/web_key.go +++ b/internal/crypto/web_key.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/json" + "fmt" "github.com/go-jose/go-jose/v4" "github.com/muhlemmer/gu" @@ -219,6 +220,8 @@ func generateWebKey(keyID string, genConfig WebKeyConfig) (private, public *jose key, err = ecdsa.GenerateKey(conf.GetCurve(), rand.Reader) case *WebKeyED25519Config: _, key, err = ed25519.GenerateKey(rand.Reader) + default: + return nil, nil, fmt.Errorf("unknown webkey config type %T", genConfig) } if err != nil { return nil, nil, err From 1719bbaba5f3a4a7aa8c29fd46414d4f65e76396 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Tue, 24 Jun 2025 23:02:12 -0700 Subject: [PATCH 102/123] chore(docs): update docusaurus to 3.8.1 (#10115) This pull request updates several dependencies in the `docs/package.json` file to their latest minor versions, ensuring compatibility and access to the latest features and fixes. Dependency updates: * Updated `@docusaurus/core`, `@docusaurus/faster`, `@docusaurus/preset-classic`, `@docusaurus/theme-mermaid`, and `@docusaurus/theme-search-algolia` from version `^3.8.0` to `^3.8.1` in the `dependencies` section. * Updated `@docusaurus/module-type-aliases` and `@docusaurus/types` from version `^3.8.0` to `^3.8.1` in the `devDependencies` section. Co-authored-by: Florian Forster --- docs/package.json | 14 +- docs/yarn.lock | 750 ++++++++++++++++++++++++---------------------- 2 files changed, 399 insertions(+), 365 deletions(-) diff --git a/docs/package.json b/docs/package.json index 014a8ec0ca..2c9eb8bb84 100644 --- a/docs/package.json +++ b/docs/package.json @@ -22,11 +22,11 @@ }, "dependencies": { "@bufbuild/buf": "^1.14.0", - "@docusaurus/core": "^3.8.0", - "@docusaurus/faster": "^3.8.0", - "@docusaurus/preset-classic": "^3.8.0", - "@docusaurus/theme-mermaid": "^3.8.0", - "@docusaurus/theme-search-algolia": "^3.8.0", + "@docusaurus/core": "^3.8.1", + "@docusaurus/faster": "^3.8.1", + "@docusaurus/preset-classic": "^3.8.1", + "@docusaurus/theme-mermaid": "^3.8.1", + "@docusaurus/theme-search-algolia": "^3.8.1", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", "autoprefixer": "^10.4.13", @@ -57,8 +57,8 @@ ] }, "devDependencies": { - "@docusaurus/module-type-aliases": "^3.8.0", - "@docusaurus/types": "^3.8.0", + "@docusaurus/module-type-aliases": "^3.8.1", + "@docusaurus/types": "^3.8.1", "tailwindcss": "^3.2.4" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/docs/yarn.lock b/docs/yarn.lock index 70f2de1f05..c933386f97 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2347,10 +2347,10 @@ resolved "https://registry.yarnpkg.com/@csstools/postcss-initial/-/postcss-initial-2.0.1.tgz#c385bd9d8ad31ad159edd7992069e97ceea4d09a" integrity sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg== -"@csstools/postcss-is-pseudo-class@^5.0.1": - version "5.0.1" - resolved "https://registry.yarnpkg.com/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.1.tgz#12041448fedf01090dd4626022c28b7f7623f58e" - integrity sha512-JLp3POui4S1auhDR0n8wHd/zTOWmMsmK3nQd3hhL6FhWPaox5W7j1se6zXOG/aP07wV2ww0lxbKYGwbBszOtfQ== +"@csstools/postcss-is-pseudo-class@^5.0.3": + version "5.0.3" + resolved "https://registry.yarnpkg.com/@csstools/postcss-is-pseudo-class/-/postcss-is-pseudo-class-5.0.3.tgz#d34e850bcad4013c2ed7abe948bfa0448aa8eb74" + integrity sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ== dependencies: "@csstools/selector-specificity" "^5.0.0" postcss-selector-parser "^7.0.0" @@ -2514,10 +2514,10 @@ resolved "https://registry.yarnpkg.com/@csstools/postcss-unset-value/-/postcss-unset-value-4.0.0.tgz#7caa981a34196d06a737754864baf77d64de4bba" integrity sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA== -"@csstools/selector-resolve-nested@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.0.0.tgz#704a9b637975680e025e069a4c58b3beb3e2752a" - integrity sha512-ZoK24Yku6VJU1gS79a5PFmC8yn3wIapiKmPgun0hZgEI5AOqgH2kiPRsPz1qkGv4HL+wuDLH83yQyk6inMYrJQ== +"@csstools/selector-resolve-nested@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@csstools/selector-resolve-nested/-/selector-resolve-nested-3.1.0.tgz#848c6f44cb65e3733e478319b9342b7aa436fac7" + integrity sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g== "@csstools/selector-specificity@^5.0.0": version "5.0.0" @@ -2549,10 +2549,10 @@ "@docsearch/css" "3.9.0" algoliasearch "^5.14.2" -"@docusaurus/babel@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.8.0.tgz#2f390cc4e588a96ec496d87921e44890899738a6" - integrity sha512-9EJwSgS6TgB8IzGk1L8XddJLhZod8fXT4ULYMx6SKqyCBqCFpVCEjR/hNXXhnmtVM2irDuzYoVLGWv7srG/VOA== +"@docusaurus/babel@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/babel/-/babel-3.8.1.tgz#db329ac047184214e08e2dbc809832c696c18506" + integrity sha512-3brkJrml8vUbn9aeoZUlJfsI/GqyFcDgQJwQkmBtclJgWDEQBKKeagZfOgx0WfUQhagL1sQLNW0iBdxnI863Uw== dependencies: "@babel/core" "^7.25.9" "@babel/generator" "^7.25.9" @@ -2564,54 +2564,54 @@ "@babel/runtime" "^7.25.9" "@babel/runtime-corejs3" "^7.25.9" "@babel/traverse" "^7.25.9" - "@docusaurus/logger" "3.8.0" - "@docusaurus/utils" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/utils" "3.8.1" babel-plugin-dynamic-import-node "^2.3.3" fs-extra "^11.1.1" tslib "^2.6.0" -"@docusaurus/bundler@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.8.0.tgz#386f54dca594d81bac6b617c71822e0808d6e2f6" - integrity sha512-Rq4Z/MSeAHjVzBLirLeMcjLIAQy92pF1OI+2rmt18fSlMARfTGLWRE8Vb+ljQPTOSfJxwDYSzsK6i7XloD2rNA== +"@docusaurus/bundler@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/bundler/-/bundler-3.8.1.tgz#e2b11d615f09a6e470774bb36441b8d06736b94c" + integrity sha512-/z4V0FRoQ0GuSLToNjOSGsk6m2lQUG4FRn8goOVoZSRsTrU8YR2aJacX5K3RG18EaX9b+52pN4m1sL3MQZVsQA== dependencies: "@babel/core" "^7.25.9" - "@docusaurus/babel" "3.8.0" - "@docusaurus/cssnano-preset" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" + "@docusaurus/babel" "3.8.1" + "@docusaurus/cssnano-preset" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" babel-loader "^9.2.1" - clean-css "^5.3.2" + clean-css "^5.3.3" copy-webpack-plugin "^11.0.0" - css-loader "^6.8.1" + css-loader "^6.11.0" css-minimizer-webpack-plugin "^5.0.1" cssnano "^6.1.2" file-loader "^6.2.0" html-minifier-terser "^7.2.0" - mini-css-extract-plugin "^2.9.1" + mini-css-extract-plugin "^2.9.2" null-loader "^4.0.1" - postcss "^8.4.26" - postcss-loader "^7.3.3" - postcss-preset-env "^10.1.0" + postcss "^8.5.4" + postcss-loader "^7.3.4" + postcss-preset-env "^10.2.1" terser-webpack-plugin "^5.3.9" tslib "^2.6.0" url-loader "^4.1.1" webpack "^5.95.0" webpackbar "^6.0.1" -"@docusaurus/core@3.8.0", "@docusaurus/core@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.8.0.tgz#79d5e1084415c8834a8a5cb87162ca13f52fe147" - integrity sha512-c7u6zFELmSGPEP9WSubhVDjgnpiHgDqMh1qVdCB7rTflh4Jx0msTYmMiO91Ez0KtHj4sIsDsASnjwfJ2IZp3Vw== +"@docusaurus/core@3.8.1", "@docusaurus/core@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-3.8.1.tgz#c22e47c16a22cb7d245306c64bc54083838ff3db" + integrity sha512-ENB01IyQSqI2FLtOzqSI3qxG2B/jP4gQPahl2C3XReiLebcVh5B5cB9KYFvdoOqOWPyr5gXK4sjgTKv7peXCrA== dependencies: - "@docusaurus/babel" "3.8.0" - "@docusaurus/bundler" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/babel" "3.8.1" + "@docusaurus/bundler" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" boxen "^6.2.1" chalk "^4.1.2" chokidar "^3.5.3" @@ -2648,23 +2648,23 @@ webpack-dev-server "^4.15.2" webpack-merge "^6.0.1" -"@docusaurus/cssnano-preset@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.0.tgz#a70f19e2995be2299f5ef9c3da3e5d4d5c14bff2" - integrity sha512-UJ4hAS2T0R4WNy+phwVff2Q0L5+RXW9cwlH6AEphHR5qw3m/yacfWcSK7ort2pMMbDn8uGrD38BTm4oLkuuNoQ== +"@docusaurus/cssnano-preset@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-3.8.1.tgz#bd55026251a6ab8e2194839a2042458ef9880c44" + integrity sha512-G7WyR2N6SpyUotqhGznERBK+x84uyhfMQM2MmDLs88bw4Flom6TY46HzkRkSEzaP9j80MbTN8naiL1fR17WQug== dependencies: cssnano-preset-advanced "^6.1.2" - postcss "^8.4.38" + postcss "^8.5.4" postcss-sort-media-queries "^5.2.0" tslib "^2.6.0" -"@docusaurus/faster@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/faster/-/faster-3.8.0.tgz#2814c5ea4f19e10a6cebf9296b6f15f8a621bf61" - integrity sha512-v9+8rT2gw/4zIRBwc4fIVhrTH/yFVDQgJgyYZjqr3fgojOypdQCOwkN6Z8dOwTei4/zo+b/zDPB4x1UvghJZRg== +"@docusaurus/faster@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/faster/-/faster-3.8.1.tgz#4db2d426f2caa754b12fa4c1264c82ab16618685" + integrity sha512-XYrj3qnTm+o2d5ih5drCq9s63GJoM8vZ26WbLG5FZhURsNxTSXgHJcx11Qo7nWPUStCQkuqk1HvItzscCUnd4A== dependencies: - "@docusaurus/types" "3.8.0" - "@rspack/core" "^1.3.10" + "@docusaurus/types" "3.8.1" + "@rspack/core" "^1.3.15" "@swc/core" "^1.7.39" "@swc/html" "^1.7.39" browserslist "^4.24.2" @@ -2673,22 +2673,22 @@ tslib "^2.6.0" webpack "^5.95.0" -"@docusaurus/logger@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.8.0.tgz#c1abbb084a8058dc0047d57070fb9cd0241a679d" - integrity sha512-7eEMaFIam5Q+v8XwGqF/n0ZoCld4hV4eCCgQkfcN9Mq5inoZa6PHHW9Wu6lmgzoK5Kx3keEeABcO2SxwraoPDQ== +"@docusaurus/logger@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-3.8.1.tgz#45321b2e2e14695d0dbd8b4104ea7b0fbaa98700" + integrity sha512-2wjeGDhKcExEmjX8k1N/MRDiPKXGF2Pg+df/bDDPnnJWHXnVEZxXj80d6jcxp1Gpnksl0hF8t/ZQw9elqj2+ww== dependencies: chalk "^4.1.2" tslib "^2.6.0" -"@docusaurus/mdx-loader@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.8.0.tgz#2b225cd2b1159cc49b10b1cac63a927a8368274b" - integrity sha512-mDPSzssRnpjSdCGuv7z2EIAnPS1MHuZGTaRLwPn4oQwszu4afjWZ/60sfKjTnjBjI8Vl4OgJl2vMmfmiNDX4Ng== +"@docusaurus/mdx-loader@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-3.8.1.tgz#74309b3614bbcef1d55fb13e6cc339b7fb000b5f" + integrity sha512-DZRhagSFRcEq1cUtBMo4TKxSNo/W6/s44yhr8X+eoXqCLycFQUylebOMPseHi5tc4fkGJqwqpWJLz6JStU9L4w== dependencies: - "@docusaurus/logger" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@mdx-js/mdx" "^3.0.0" "@slorber/remark-comment" "^1.0.0" escape-html "^1.0.3" @@ -2711,12 +2711,12 @@ vfile "^6.0.1" webpack "^5.88.1" -"@docusaurus/module-type-aliases@3.8.0", "@docusaurus/module-type-aliases@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.0.tgz#e487052c372538c5dcf2200999e13f328fa5ffaa" - integrity sha512-/uMb4Ipt5J/QnD13MpnoC/A4EYAe6DKNWqTWLlGrqsPJwJv73vSwkA25xnYunwfqWk0FlUQfGv/Swdh5eCCg7g== +"@docusaurus/module-type-aliases@3.8.1", "@docusaurus/module-type-aliases@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-3.8.1.tgz#454de577bd7f50b5eae16db0f76b49ca5e4e281a" + integrity sha512-6xhvAJiXzsaq3JdosS7wbRt/PwEPWHr9eM4YNYqVlbgG1hSK3uQDXTVvQktasp3VO6BmfYWPozueLWuj4gB+vg== dependencies: - "@docusaurus/types" "3.8.0" + "@docusaurus/types" "3.8.1" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" @@ -2724,19 +2724,19 @@ react-helmet-async "npm:@slorber/react-helmet-async@1.3.0" react-loadable "npm:@docusaurus/react-loadable@6.0.0" -"@docusaurus/plugin-content-blog@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.0.tgz#0c200b1fb821e09e9e975c45255e5ddfab06c392" - integrity sha512-0SlOTd9R55WEr1GgIXu+hhTT0hzARYx3zIScA5IzpdekZQesI/hKEa5LPHBd415fLkWMjdD59TaW/3qQKpJ0Lg== +"@docusaurus/plugin-content-blog@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.8.1.tgz#88d842b562b04cf59df900d9f6984b086f821525" + integrity sha512-vNTpMmlvNP9n3hGEcgPaXyvTljanAKIUkuG9URQ1DeuDup0OR7Ltvoc8yrmH+iMZJbcQGhUJF+WjHLwuk8HSdw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" cheerio "1.0.0-rc.12" feed "^4.2.2" fs-extra "^11.1.1" @@ -2748,20 +2748,20 @@ utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-docs@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.0.tgz#6aedb1261da1f0c8c2fa11cfaa6df4577a9b7826" - integrity sha512-fRDMFLbUN6eVRXcjP8s3Y7HpAt9pzPYh1F/7KKXOCxvJhjjCtbon4VJW0WndEPInVz4t8QUXn5QZkU2tGVCE2g== +"@docusaurus/plugin-content-docs@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.8.1.tgz#40686a206abb6373bee5638de100a2c312f112a4" + integrity sha512-oByRkSZzeGNQByCMaX+kif5Nl2vmtj2IHQI2fWjCfCootsdKZDPFLonhIp5s3IGJO7PLUfe0POyw0Xh/RrGXJA== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@types/react-router-config" "^5.0.7" combine-promises "^1.1.0" fs-extra "^11.1.1" @@ -2772,148 +2772,149 @@ utility-types "^3.10.0" webpack "^5.88.1" -"@docusaurus/plugin-content-pages@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.0.tgz#2db5f990872684c621665d0d0d8d9b5831fd2999" - integrity sha512-39EDx2y1GA0Pxfion5tQZLNJxL4gq6susd1xzetVBjVIQtwpCdyloOfQBAgX0FylqQxfJrYqL0DIUuq7rd7uBw== +"@docusaurus/plugin-content-pages@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-3.8.1.tgz#41b684dbd15390b7bb6a627f78bf81b6324511ac" + integrity sha512-a+V6MS2cIu37E/m7nDJn3dcxpvXb6TvgdNI22vJX8iUTp8eoMoPa0VArEbWvCxMY/xdC26WzNv4wZ6y0iIni/w== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" fs-extra "^11.1.1" tslib "^2.6.0" webpack "^5.88.1" -"@docusaurus/plugin-css-cascade-layers@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.0.tgz#a0741ae32917a88ce7ce76b6f472495fa4bf576d" - integrity sha512-/VBTNymPIxQB8oA3ZQ4GFFRYdH4ZxDRRBECxyjRyv486mfUPXfcdk+im4S5mKWa6EK2JzBz95IH/Wu0qQgJ5yQ== +"@docusaurus/plugin-css-cascade-layers@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-css-cascade-layers/-/plugin-css-cascade-layers-3.8.1.tgz#cb414b4a82aa60fc64ef2a435ad0105e142a6c71" + integrity sha512-VQ47xRxfNKjHS5ItzaVXpxeTm7/wJLFMOPo1BkmoMG4Cuz4nuI+Hs62+RMk1OqVog68Swz66xVPK8g9XTrBKRw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" tslib "^2.6.0" -"@docusaurus/plugin-debug@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.8.0.tgz#297c159ae99924e60042426d2ad6ee0d5e9126b3" - integrity sha512-teonJvJsDB9o2OnG6ifbhblg/PXzZvpUKHFgD8dOL1UJ58u0lk8o0ZOkvaYEBa9nDgqzoWrRk9w+e3qaG2mOhQ== +"@docusaurus/plugin-debug@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-3.8.1.tgz#45b107e46b627caaae66995f53197ace78af3491" + integrity sha512-nT3lN7TV5bi5hKMB7FK8gCffFTBSsBsAfV84/v293qAmnHOyg1nr9okEw8AiwcO3bl9vije5nsUvP0aRl2lpaw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" fs-extra "^11.1.1" react-json-view-lite "^2.3.0" tslib "^2.6.0" -"@docusaurus/plugin-google-analytics@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.0.tgz#fb97097af331beb13553a384081dc83607539b31" - integrity sha512-aKKa7Q8+3xRSRESipNvlFgNp3FNPELKhuo48Cg/svQbGNwidSHbZT03JqbW4cBaQnyyVchO1ttk+kJ5VC9Gx0w== +"@docusaurus/plugin-google-analytics@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-3.8.1.tgz#64a302e62fe5cb6e007367c964feeef7b056764a" + integrity sha512-Hrb/PurOJsmwHAsfMDH6oVpahkEGsx7F8CWMjyP/dw1qjqmdS9rcV1nYCGlM8nOtD3Wk/eaThzUB5TSZsGz+7Q== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" tslib "^2.6.0" -"@docusaurus/plugin-google-gtag@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.0.tgz#b5a60006c28ac582859a469fb92e53d383b0a055" - integrity sha512-ugQYMGF4BjbAW/JIBtVcp+9eZEgT9HRdvdcDudl5rywNPBA0lct+lXMG3r17s02rrhInMpjMahN3Yc9Cb3H5/g== +"@docusaurus/plugin-google-gtag@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-3.8.1.tgz#8c76f8a1d96448f2f0f7b10e6bde451c40672b95" + integrity sha512-tKE8j1cEZCh8KZa4aa80zpSTxsC2/ZYqjx6AAfd8uA8VHZVw79+7OTEP2PoWi0uL5/1Is0LF5Vwxd+1fz5HlKg== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@types/gtag.js" "^0.0.12" tslib "^2.6.0" -"@docusaurus/plugin-google-tag-manager@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.0.tgz#612aa63e161fb273bf7db2591034c0142951727d" - integrity sha512-9juRWxbwZD3SV02Jd9QB6yeN7eu+7T4zB0bvJLcVQwi+am51wAxn2CwbdL0YCCX+9OfiXbADE8D8Q65Hbopu/w== +"@docusaurus/plugin-google-tag-manager@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-3.8.1.tgz#88241ffd06369f4a4d5fb982ff3ac2777561ae37" + integrity sha512-iqe3XKITBquZq+6UAXdb1vI0fPY5iIOitVjPQ581R1ZKpHr0qe+V6gVOrrcOHixPDD/BUKdYwkxFjpNiEN+vBw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" tslib "^2.6.0" -"@docusaurus/plugin-sitemap@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.0.tgz#a39e3b5aa2f059aba0052ed11a6b4fbf78ac0dad" - integrity sha512-fGpOIyJvNiuAb90nSJ2Gfy/hUOaDu6826e5w5UxPmbpCIc7KlBHNAZ5g4L4ZuHhc4hdfq4mzVBsQSnne+8Ze1g== +"@docusaurus/plugin-sitemap@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-3.8.1.tgz#3aebd39186dc30e53023f1aab44625bc0bdac892" + integrity sha512-+9YV/7VLbGTq8qNkjiugIelmfUEVkTyLe6X8bWq7K5qPvGXAjno27QAfFq63mYfFFbJc7z+pudL63acprbqGzw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" fs-extra "^11.1.1" sitemap "^7.1.1" tslib "^2.6.0" -"@docusaurus/plugin-svgr@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.0.tgz#6d2d43f14b32b4bb2dd8dc87a70c6e78754c1e85" - integrity sha512-kEDyry+4OMz6BWLG/lEqrNsL/w818bywK70N1gytViw4m9iAmoxCUT7Ri9Dgs7xUdzCHJ3OujolEmD88Wy44OA== +"@docusaurus/plugin-svgr@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/plugin-svgr/-/plugin-svgr-3.8.1.tgz#6f340be8eae418a2cce540d8ece096ffd9c9b6ab" + integrity sha512-rW0LWMDsdlsgowVwqiMb/7tANDodpy1wWPwCcamvhY7OECReN3feoFwLjd/U4tKjNY3encj0AJSTxJA+Fpe+Gw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@svgr/core" "8.1.0" "@svgr/webpack" "^8.1.0" tslib "^2.6.0" webpack "^5.88.1" -"@docusaurus/preset-classic@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.8.0.tgz#ac8bc17e3b7b443d8a24f2f1da0c0be396950fef" - integrity sha512-qOu6tQDOWv+rpTlKu+eJATCJVGnABpRCPuqf7LbEaQ1mNY//N/P8cHQwkpAU+aweQfarcZ0XfwCqRHJfjeSV/g== +"@docusaurus/preset-classic@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-3.8.1.tgz#bb79fd12f3211363720c569a526c7e24d3aa966b" + integrity sha512-yJSjYNHXD8POMGc2mKQuj3ApPrN+eG0rO1UPgSx7jySpYU+n4WjBikbrA2ue5ad9A7aouEtMWUoiSRXTH/g7KQ== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/plugin-content-blog" "3.8.0" - "@docusaurus/plugin-content-docs" "3.8.0" - "@docusaurus/plugin-content-pages" "3.8.0" - "@docusaurus/plugin-css-cascade-layers" "3.8.0" - "@docusaurus/plugin-debug" "3.8.0" - "@docusaurus/plugin-google-analytics" "3.8.0" - "@docusaurus/plugin-google-gtag" "3.8.0" - "@docusaurus/plugin-google-tag-manager" "3.8.0" - "@docusaurus/plugin-sitemap" "3.8.0" - "@docusaurus/plugin-svgr" "3.8.0" - "@docusaurus/theme-classic" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/theme-search-algolia" "3.8.0" - "@docusaurus/types" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/plugin-content-blog" "3.8.1" + "@docusaurus/plugin-content-docs" "3.8.1" + "@docusaurus/plugin-content-pages" "3.8.1" + "@docusaurus/plugin-css-cascade-layers" "3.8.1" + "@docusaurus/plugin-debug" "3.8.1" + "@docusaurus/plugin-google-analytics" "3.8.1" + "@docusaurus/plugin-google-gtag" "3.8.1" + "@docusaurus/plugin-google-tag-manager" "3.8.1" + "@docusaurus/plugin-sitemap" "3.8.1" + "@docusaurus/plugin-svgr" "3.8.1" + "@docusaurus/theme-classic" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/theme-search-algolia" "3.8.1" + "@docusaurus/types" "3.8.1" -"@docusaurus/theme-classic@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.8.0.tgz#6d44fb801b86a7c7af01cda0325af1a3300b3ac2" - integrity sha512-nQWFiD5ZjoT76OaELt2n33P3WVuuCz8Dt5KFRP2fCBo2r9JCLsp2GJjZpnaG24LZ5/arRjv4VqWKgpK0/YLt7g== +"@docusaurus/theme-classic@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-3.8.1.tgz#1e45c66d89ded359225fcd29bf3258d9205765c1" + integrity sha512-bqDUCNqXeYypMCsE1VcTXSI1QuO4KXfx8Cvl6rYfY0bhhqN6d2WZlRkyLg/p6pm+DzvanqHOyYlqdPyP0iz+iw== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/plugin-content-blog" "3.8.0" - "@docusaurus/plugin-content-docs" "3.8.0" - "@docusaurus/plugin-content-pages" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/theme-translations" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/plugin-content-blog" "3.8.1" + "@docusaurus/plugin-content-docs" "3.8.1" + "@docusaurus/plugin-content-pages" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/theme-translations" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" "@mdx-js/react" "^3.0.0" clsx "^2.0.0" copy-text-to-clipboard "^3.2.0" infima "0.2.0-alpha.45" lodash "^4.17.21" nprogress "^0.2.0" - postcss "^8.4.26" + postcss "^8.5.4" prism-react-renderer "^2.3.0" prismjs "^1.29.0" react-router-dom "^5.3.4" @@ -2921,15 +2922,15 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-common@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.8.0.tgz#102c385c3d1d3b7a6b52d1911c7e88c38d9a977e" - integrity sha512-YqV2vAWpXGLA+A3PMLrOMtqgTHJLDcT+1Caa6RF7N4/IWgrevy5diY8oIHFkXR/eybjcrFFjUPrHif8gSGs3Tw== +"@docusaurus/theme-common@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-3.8.1.tgz#17c23316fbe3ee3f7e707c7298cb59a0fff38b4b" + integrity sha512-UswMOyTnPEVRvN5Qzbo+l8k4xrd5fTFu2VPPfD6FcW/6qUtVLmJTQCktbAL3KJ0BVXGm5aJXz/ZrzqFuZERGPw== dependencies: - "@docusaurus/mdx-loader" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" + "@docusaurus/mdx-loader" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" "@types/history" "^4.7.11" "@types/react" "*" "@types/react-router-config" "*" @@ -2939,32 +2940,32 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-mermaid@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.8.0.tgz#f0720ec89fd386870f30978ba984b1b126ca92a5" - integrity sha512-ou0NJM37p4xrVuFaZp8qFe5Z/qBq9LuyRTP4KKRa0u2J3zC4f3saBJDgc56FyvvN1OsmU0189KGEPUjTr6hFxg== +"@docusaurus/theme-mermaid@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-mermaid/-/theme-mermaid-3.8.1.tgz#2b73b5e90057bd9fb46f267aeb2d3470b168a7c8" + integrity sha512-IWYqjyTPjkNnHsFFu9+4YkeXS7PD1xI3Bn2shOhBq+f95mgDfWInkpfBN4aYvx4fTT67Am6cPtohRdwh4Tidtg== dependencies: - "@docusaurus/core" "3.8.0" - "@docusaurus/module-type-aliases" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/module-type-aliases" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" mermaid ">=11.6.0" tslib "^2.6.0" -"@docusaurus/theme-search-algolia@3.8.0", "@docusaurus/theme-search-algolia@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.0.tgz#21c2f18e07a73d13ca3b44fcf0ae9aac33bef60f" - integrity sha512-GBZ5UOcPgiu6nUw153+0+PNWvFKweSnvKIL6Rp04H9olKb475jfKjAwCCtju5D2xs5qXHvCMvzWOg5o9f6DtuQ== +"@docusaurus/theme-search-algolia@3.8.1", "@docusaurus/theme-search-algolia@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-3.8.1.tgz#3aa3d99c35cc2d4b709fcddd4df875a9b536e29b" + integrity sha512-NBFH5rZVQRAQM087aYSRKQ9yGEK9eHd+xOxQjqNpxMiV85OhJDD4ZGz6YJIod26Fbooy54UWVdzNU0TFeUUUzQ== dependencies: "@docsearch/react" "^3.9.0" - "@docusaurus/core" "3.8.0" - "@docusaurus/logger" "3.8.0" - "@docusaurus/plugin-content-docs" "3.8.0" - "@docusaurus/theme-common" "3.8.0" - "@docusaurus/theme-translations" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-validation" "3.8.0" + "@docusaurus/core" "3.8.1" + "@docusaurus/logger" "3.8.1" + "@docusaurus/plugin-content-docs" "3.8.1" + "@docusaurus/theme-common" "3.8.1" + "@docusaurus/theme-translations" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-validation" "3.8.1" algoliasearch "^5.17.1" algoliasearch-helper "^3.22.6" clsx "^2.0.0" @@ -2974,18 +2975,18 @@ tslib "^2.6.0" utility-types "^3.10.0" -"@docusaurus/theme-translations@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.8.0.tgz#deb64dccab74361624c3cb352a4949a7ac868c74" - integrity sha512-1DTy/snHicgkCkryWq54fZvsAglTdjTx4qjOXgqnXJ+DIty1B+aPQrAVUu8LiM+6BiILfmNxYsxhKTj+BS3PZg== +"@docusaurus/theme-translations@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-3.8.1.tgz#4b1d76973eb53861e167c7723485e059ba4ffd0a" + integrity sha512-OTp6eebuMcf2rJt4bqnvuwmm3NVXfzfYejL+u/Y1qwKhZPrjPoKWfk1CbOP5xH5ZOPkiAsx4dHdQBRJszK3z2g== dependencies: fs-extra "^11.1.1" tslib "^2.6.0" -"@docusaurus/types@3.8.0", "@docusaurus/types@^3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.8.0.tgz#f6cd31c4e3e392e0270b8137d7fe4365ea7a022e" - integrity sha512-RDEClpwNxZq02c+JlaKLWoS13qwWhjcNsi2wG1UpzmEnuti/z1Wx4SGpqbUqRPNSd8QWWePR8Cb7DvG0VN/TtA== +"@docusaurus/types@3.8.1", "@docusaurus/types@^3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-3.8.1.tgz#83ab66c345464e003b576a49f78897482061fc26" + integrity sha512-ZPdW5AB+pBjiVrcLuw3dOS6BFlrG0XkS2lDGsj8TizcnREQg3J8cjsgfDviszOk4CweNfwo1AEELJkYaMUuOPg== dependencies: "@mdx-js/mdx" "^3.0.0" "@types/history" "^4.7.11" @@ -3012,36 +3013,36 @@ webpack "^5.88.1" webpack-merge "^5.9.0" -"@docusaurus/utils-common@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.8.0.tgz#2b1a6b1ec4a7fac62f1898d523d42f8cc4a8258f" - integrity sha512-3TGF+wVTGgQ3pAc9+5jVchES4uXUAhAt9pwv7uws4mVOxL4alvU3ue/EZ+R4XuGk94pDy7CNXjRXpPjlfZXQfw== +"@docusaurus/utils-common@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-3.8.1.tgz#c369b8c3041afb7dcd595d4172beb1cc1015c85f" + integrity sha512-zTZiDlvpvoJIrQEEd71c154DkcriBecm4z94OzEE9kz7ikS3J+iSlABhFXM45mZ0eN5pVqqr7cs60+ZlYLewtg== dependencies: - "@docusaurus/types" "3.8.0" + "@docusaurus/types" "3.8.1" tslib "^2.6.0" -"@docusaurus/utils-validation@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.8.0.tgz#aa02e9d998e20998fbcaacd94873878bc3b9a4cd" - integrity sha512-MrnEbkigr54HkdFeg8e4FKc4EF+E9dlVwsY3XQZsNkbv3MKZnbHQ5LsNJDIKDROFe8PBf5C4qCAg5TPBpsjrjg== +"@docusaurus/utils-validation@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-3.8.1.tgz#0499c0d151a4098a0963237057993282cfbd538e" + integrity sha512-gs5bXIccxzEbyVecvxg6upTwaUbfa0KMmTj7HhHzc016AGyxH2o73k1/aOD0IFrdCsfJNt37MqNI47s2MgRZMA== dependencies: - "@docusaurus/logger" "3.8.0" - "@docusaurus/utils" "3.8.0" - "@docusaurus/utils-common" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/utils" "3.8.1" + "@docusaurus/utils-common" "3.8.1" fs-extra "^11.2.0" joi "^17.9.2" js-yaml "^4.1.0" lodash "^4.17.21" tslib "^2.6.0" -"@docusaurus/utils@3.8.0": - version "3.8.0" - resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.8.0.tgz#92bad89d2a11f5f246196af153093b12cd79f9ac" - integrity sha512-2wvtG28ALCN/A1WCSLxPASFBFzXCnP0YKCAFIPcvEb6imNu1wg7ni/Svcp71b3Z2FaOFFIv4Hq+j4gD7gA0yfQ== +"@docusaurus/utils@3.8.1": + version "3.8.1" + resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-3.8.1.tgz#2ac1e734106e2f73dbd0f6a8824d525f9064e9f0" + integrity sha512-P1ml0nvOmEFdmu0smSXOqTS1sxU5tqvnc0dA4MTKV39kye+bhQnjkIKEE18fNOvxjyB86k8esoCIFM3x4RykOQ== dependencies: - "@docusaurus/logger" "3.8.0" - "@docusaurus/types" "3.8.0" - "@docusaurus/utils-common" "3.8.0" + "@docusaurus/logger" "3.8.1" + "@docusaurus/types" "3.8.1" + "@docusaurus/utils-common" "3.8.1" escape-string-regexp "^4.0.0" execa "5.1.1" file-loader "^6.2.0" @@ -3244,48 +3245,48 @@ dependencies: langium "3.3.1" -"@module-federation/error-codes@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/error-codes/-/error-codes-0.14.0.tgz#d54581bfb998ce9ace4cb33f8795c644e461bfeb" - integrity sha512-GGk+EoeSACJikZZyShnLshtq9E2eCrDWbRiB4QAFXCX4oYmGgFfzXlx59vMNwqTKPJWxkEGnPYacJMcr2YYjag== +"@module-federation/error-codes@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/error-codes/-/error-codes-0.14.3.tgz#e6b5c380240f5650bcf67a1906b22271b891d2c5" + integrity sha512-sBJ3XKU9g5Up31jFeXPFsD8AgORV7TLO/cCSMuRewSfgYbG/3vSKLJmfHrO6+PvjZSb9VyV2UaF02ojktW65vw== -"@module-federation/runtime-core@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/runtime-core/-/runtime-core-0.14.0.tgz#a00a3666cc25a8bb822a36552c631e6e3f7326cc" - integrity sha512-fGE1Ro55zIFDp/CxQuRhKQ1pJvG7P0qvRm2N+4i8z++2bgDjcxnCKUqDJ8lLD+JfJQvUJf0tuSsJPgevzueD4g== +"@module-federation/runtime-core@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/runtime-core/-/runtime-core-0.14.3.tgz#434025c1304278e30bbc024aaeab086d80f9e196" + integrity sha512-xMFQXflLVW/AJTWb4soAFP+LB4XuhE7ryiLIX8oTyUoBBgV6U2OPghnFljPjeXbud72O08NYlQ1qsHw1kN/V8Q== dependencies: - "@module-federation/error-codes" "0.14.0" - "@module-federation/sdk" "0.14.0" + "@module-federation/error-codes" "0.14.3" + "@module-federation/sdk" "0.14.3" -"@module-federation/runtime-tools@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.14.0.tgz#f5b5f3d19605b6d7c90ed278dc00a5400f1aa49d" - integrity sha512-y/YN0c2DKsLETE+4EEbmYWjqF9G6ZwgZoDIPkaQ9p0pQu0V4YxzWfQagFFxR0RigYGuhJKmSU/rtNoHq+qF8jg== +"@module-federation/runtime-tools@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/runtime-tools/-/runtime-tools-0.14.3.tgz#fa1414b449cbe5fb6dcbde4ed02c85e0cdcc758b" + integrity sha512-QBETX7iMYXdSa3JtqFlYU+YkpymxETZqyIIRiqg0gW+XGpH3jgU68yjrme2NBJp7URQi/CFZG8KWtfClk0Pjgw== dependencies: - "@module-federation/runtime" "0.14.0" - "@module-federation/webpack-bundler-runtime" "0.14.0" + "@module-federation/runtime" "0.14.3" + "@module-federation/webpack-bundler-runtime" "0.14.3" -"@module-federation/runtime@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.14.0.tgz#e04012d2d928275fd00525904c0b4f8be514dc70" - integrity sha512-kR3cyHw/Y64SEa7mh4CHXOEQYY32LKLK75kJOmBroLNLO7/W01hMNAvGBYTedS7hWpVuefPk1aFZioy3q2VLdQ== +"@module-federation/runtime@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/runtime/-/runtime-0.14.3.tgz#fc9142c093001c67a0fcacaf53d4eb5749e9bbd6" + integrity sha512-7ZHpa3teUDVhraYdxQGkfGHzPbjna4LtwbpudgzAxSLLFxLDNanaxCuSeIgSM9c+8sVUNC9kvzUgJEZB0krPJw== dependencies: - "@module-federation/error-codes" "0.14.0" - "@module-federation/runtime-core" "0.14.0" - "@module-federation/sdk" "0.14.0" + "@module-federation/error-codes" "0.14.3" + "@module-federation/runtime-core" "0.14.3" + "@module-federation/sdk" "0.14.3" -"@module-federation/sdk@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.14.0.tgz#efa38341b7601f58967397cc630068f69691b931" - integrity sha512-lg/OWRsh18hsyTCamOOhEX546vbDiA2O4OggTxxH2wTGr156N6DdELGQlYIKfRdU/0StgtQS81Goc0BgDZlx9A== +"@module-federation/sdk@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/sdk/-/sdk-0.14.3.tgz#a03e37f1cb018283542cfc66a87e7a37e39cfe1a" + integrity sha512-THJZMfbXpqjQOLblCQ8jjcBFFXsGRJwUWE9l/Q4SmuCSKMgAwie7yLT0qSGrHmyBYrsUjAuy+xNB4nfKP0pnGw== -"@module-federation/webpack-bundler-runtime@0.14.0": - version "0.14.0" - resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.14.0.tgz#21a82505f95fdb3cb202786f8dc20611b4d7f93c" - integrity sha512-POWS6cKBicAAQ3DNY5X7XEUSfOfUsRaBNxbuwEfSGlrkTE9UcWheO06QP2ndHi8tHQuUKcIHi2navhPkJ+k5xg== +"@module-federation/webpack-bundler-runtime@0.14.3": + version "0.14.3" + resolved "https://registry.yarnpkg.com/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.14.3.tgz#2d6bf63e93f626a2f5e469bc57fb5bcc098fee37" + integrity sha512-hIyJFu34P7bY2NeMIUHAS/mYUHEY71VTAsN0A0AqEJFSVPszheopu9VdXq0VDLrP9KQfuXT8SDxeYeJXyj0mgA== dependencies: - "@module-federation/runtime" "0.14.0" - "@module-federation/sdk" "0.14.0" + "@module-federation/runtime" "0.14.3" + "@module-federation/sdk" "0.14.3" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -3469,75 +3470,74 @@ redux-thunk "^2.4.2" reselect "^4.1.8" -"@rspack/binding-darwin-arm64@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.3.12.tgz#2e7cc00b813dcb155572908d956ab1d75d9747a5" - integrity sha512-8hKjVTBeWPqkMzFPNWIh72oU9O3vFy3e88wRjMPImDCXBiEYrKqGTTLd/J0SO+efdL3SBD1rX1IvdJpxCv6Yrw== +"@rspack/binding-darwin-arm64@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.3.15.tgz#8deb8845dbb6285e40dd329b9ad13fcbaf6be8f4" + integrity sha512-f+DnVRENRdVe+ufpZeqTtWAUDSTnP48jVo7x9KWsXf8XyJHUi+eHKEPrFoy1HvL1/k5yJ3HVnFBh1Hb9cNIwSg== -"@rspack/binding-darwin-x64@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.3.12.tgz#cb148cc62658d74204621a695e1698cda82877f9" - integrity sha512-Sj4m+mCUxL7oCpdu7OmWT7fpBM7hywk5CM9RDc3D7StaBZbvNtNftafCrTZzTYKuZrKmemTh5SFzT5Tz7tf6GA== +"@rspack/binding-darwin-x64@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.3.15.tgz#a7dc05a5d278c2c1fd920987afb0b839311bff89" + integrity sha512-TfUvEIBqYUT2OK01BYXb2MNcZeZIhAnJy/5aj0qV0uy4KlvwW63HYcKWa1sFd4Ac7bnGShDkanvP3YEuHOFOyg== -"@rspack/binding-linux-arm64-gnu@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.3.12.tgz#79820857dfbd3819e0026da507c271531c013d0c" - integrity sha512-7MuOxf3/Mhv4mgFdLTvgnt/J+VouNR65DEhorth+RZm3LEWojgoFEphSAMAvpvAOpYSS68Sw4SqsOZi719ia2w== +"@rspack/binding-linux-arm64-gnu@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.3.15.tgz#31a29d4aac66fd92232bccbb2be0273362e1d6f2" + integrity sha512-D/YjYk9snKvYm1Elotq8/GsEipB4ZJWVv/V8cZ+ohhFNOPzygENi6JfyI06TryBTQiN0/JDZqt/S9RaWBWnMqw== -"@rspack/binding-linux-arm64-musl@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.3.12.tgz#a794dfe8df6bf75af217597881592add6f6b046e" - integrity sha512-s6KKj20T9Z1bA8caIjU6EzJbwyDo1URNFgBAlafCT2UC6yX7flstDJJ38CxZacA9A2P24RuQK2/jPSZpWrTUFA== +"@rspack/binding-linux-arm64-musl@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.3.15.tgz#f8bdc956f6ef644b1c469d20ecc3849415f3e71b" + integrity sha512-lJbBsPMOiR0hYPCSM42yp7QiZjfo0ALtX7ws2wURpsQp3BMfRVAmXU3Ixpo2XCRtG1zj8crHaCmAWOJTS0smsA== -"@rspack/binding-linux-x64-gnu@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.3.12.tgz#a0e23831a1374d9039a4b29e927cef58a485085c" - integrity sha512-0w/sRREYbRgHgWvs2uMEJSLfvzbZkPHUg6CMcYQGNVK6axYRot6jPyKetyFYA9pR5fB5rsXegpnFaZaVrRIK2g== +"@rspack/binding-linux-x64-gnu@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.3.15.tgz#5dd39fd59eb5d3f8e353110f3ed40e00242c73f8" + integrity sha512-qGB8ucHklrzNg6lsAS36VrBsCbOw0acgpQNqTE5cuHWrp1Pu3GFTRiFEogenxEmzoRbohMZt0Ev5grivrcgKBQ== -"@rspack/binding-linux-x64-musl@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.3.12.tgz#61620efda6a6805689890c130e6b38c1725baaf4" - integrity sha512-jEdxkPymkRxbijDRsBGdhopcbGXiXDg59lXqIRkVklqbDmZ/O6DHm7gImmlx5q9FoWbz0gqJuOKBz4JqWxjWVA== +"@rspack/binding-linux-x64-musl@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.3.15.tgz#32c2bb197568cca841a26ecea019590bc0e2ce56" + integrity sha512-qRn6e40fLQP+N2rQD8GAj/h4DakeTIho32VxTIaHRVuzw68ZD7VmKkwn55ssN370ejmey35ZdoNFNE12RBrMZA== -"@rspack/binding-win32-arm64-msvc@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.3.12.tgz#46d874df8bd5b84e82ea83480969f7e0293ccd82" - integrity sha512-ZRvUCb3TDLClAqcTsl/o9UdJf0B5CgzAxgdbnYJbldyuyMeTUB4jp20OfG55M3C2Nute2SNhu2bOOp9Se5Ongw== +"@rspack/binding-win32-arm64-msvc@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.3.15.tgz#75c1b04d2ea08b49f480825ad57d8ca82acaf6d9" + integrity sha512-7uJ7dWhO1nWXJiCss6Rslz8hoAxAhFpwpbWja3eHgRb7O4NPHg6MWw63AQSI2aFVakreenfu9yXQqYfpVWJ2dA== -"@rspack/binding-win32-ia32-msvc@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.3.12.tgz#fec435e31e56f3d58b4fa52746643d1d593b2b89" - integrity sha512-1TKPjuXStPJr14f3ZHuv40Xc/87jUXx10pzVtrPnw+f3hckECHrbYU/fvbVzZyuXbsXtkXpYca6ygCDRJAoNeQ== +"@rspack/binding-win32-ia32-msvc@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.3.15.tgz#56093d8818d68cd270ba63bddffd38dbd50957f7" + integrity sha512-UsaWTYCjDiSCB0A0qETgZk4QvhwfG8gCrO4SJvA+QSEWOmgSai1YV70prFtLLIiyT9mDt1eU3tPWl1UWPRU/EQ== -"@rspack/binding-win32-x64-msvc@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.3.12.tgz#33b73cbab75920cf8a92e7245a794970f8508b6c" - integrity sha512-lCR0JfnYKpV+a6r2A2FdxyUKUS4tajePgpPJN5uXDgMGwrDtRqvx+d0BHhwjFudQVJq9VVbRaL89s2MQ6u+xYw== +"@rspack/binding-win32-x64-msvc@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.3.15.tgz#eb404ff76ea9da045173e6efccc6b7f276fd6960" + integrity sha512-ZnDIc9Es8EF94MirPDN+hOMt7tkb8nMEbRJFKLMmNd0ElNPgsql+1cY5SqyGRH1hsKB87KfSUQlhFiKZvzbfIg== -"@rspack/binding@1.3.12": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/binding/-/binding-1.3.12.tgz#0a8356fdbd89f08cda3e9bb8aff4ea781dfe972e" - integrity sha512-4Ic8lV0+LCBfTlH5aIOujIRWZOtgmG223zC4L3o8WY/+ESAgpdnK6lSSMfcYgRanYLAy3HOmFIp20jwskMpbAg== +"@rspack/binding@1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/binding/-/binding-1.3.15.tgz#535fa1f14d173fb72a2d8bb7df10906827e77185" + integrity sha512-utNPuJglLO5lW9XbwIqjB7+2ilMo6JkuVLTVdnNVKU94FW7asn9F/qV+d+MgjUVqU1QPCGm0NuGO9xhbgeJ7pg== optionalDependencies: - "@rspack/binding-darwin-arm64" "1.3.12" - "@rspack/binding-darwin-x64" "1.3.12" - "@rspack/binding-linux-arm64-gnu" "1.3.12" - "@rspack/binding-linux-arm64-musl" "1.3.12" - "@rspack/binding-linux-x64-gnu" "1.3.12" - "@rspack/binding-linux-x64-musl" "1.3.12" - "@rspack/binding-win32-arm64-msvc" "1.3.12" - "@rspack/binding-win32-ia32-msvc" "1.3.12" - "@rspack/binding-win32-x64-msvc" "1.3.12" + "@rspack/binding-darwin-arm64" "1.3.15" + "@rspack/binding-darwin-x64" "1.3.15" + "@rspack/binding-linux-arm64-gnu" "1.3.15" + "@rspack/binding-linux-arm64-musl" "1.3.15" + "@rspack/binding-linux-x64-gnu" "1.3.15" + "@rspack/binding-linux-x64-musl" "1.3.15" + "@rspack/binding-win32-arm64-msvc" "1.3.15" + "@rspack/binding-win32-ia32-msvc" "1.3.15" + "@rspack/binding-win32-x64-msvc" "1.3.15" -"@rspack/core@^1.3.10": - version "1.3.12" - resolved "https://registry.yarnpkg.com/@rspack/core/-/core-1.3.12.tgz#68df0111cfac7e8f9dfa11a608ac8731181b5483" - integrity sha512-mAPmV4LPPRgxpouUrGmAE4kpF1NEWJGyM5coebsjK/zaCMSjw3mkdxiU2b5cO44oIi0Ifv5iGkvwbdrZOvMyFA== +"@rspack/core@^1.3.15": + version "1.3.15" + resolved "https://registry.yarnpkg.com/@rspack/core/-/core-1.3.15.tgz#22bce959aa386c3f38021af6ee6a94339896ffd2" + integrity sha512-QuElIC8jXSKWAp0LSx18pmbhA7NiA5HGoVYesmai90UVxz98tud0KpMxTVCg+0lrLrnKZfCWN9kwjCxM5pGnrA== dependencies: - "@module-federation/runtime-tools" "0.14.0" - "@rspack/binding" "1.3.12" + "@module-federation/runtime-tools" "0.14.3" + "@rspack/binding" "1.3.15" "@rspack/lite-tapable" "1.0.1" - caniuse-lite "^1.0.30001718" "@rspack/lite-tapable@1.0.1": version "1.0.1" @@ -5163,7 +5163,7 @@ browserslist@^4.0.0, browserslist@^4.21.10, browserslist@^4.22.2, browserslist@^ node-releases "^2.0.14" update-browserslist-db "^1.0.16" -browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4, browserslist@^4.24.5: +browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4: version "4.24.5" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.5.tgz#aa0f5b8560fe81fde84c6dcb38f759bafba0e11b" integrity sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw== @@ -5173,6 +5173,16 @@ browserslist@^4.24.0, browserslist@^4.24.2, browserslist@^4.24.4, browserslist@^ node-releases "^2.0.19" update-browserslist-db "^1.1.3" +browserslist@^4.25.0: + version "4.25.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c" + integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA== + dependencies: + caniuse-lite "^1.0.30001718" + electron-to-chromium "^1.5.160" + node-releases "^2.0.19" + update-browserslist-db "^1.1.3" + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" @@ -5445,7 +5455,7 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -clean-css@^5.2.2, clean-css@^5.3.2, clean-css@~5.3.2: +clean-css@^5.2.2, clean-css@^5.3.3, clean-css@~5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" integrity sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg== @@ -5834,7 +5844,7 @@ css-has-pseudo@^7.0.2: postcss-selector-parser "^7.0.0" postcss-value-parser "^4.2.0" -css-loader@^6.8.1: +css-loader@^6.11.0: version "6.11.0" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.11.0.tgz#33bae3bf6363d0a7c2cf9031c96c744ff54d85ba" integrity sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g== @@ -6665,6 +6675,11 @@ electron-to-chromium@^1.5.149: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz#e5f01fc7fdf810d9d223e30593e0839c306276d4" integrity sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ== +electron-to-chromium@^1.5.160: + version "1.5.172" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.172.tgz#fe1d99028d8d6321668d0f1fed61d99ac896259c" + integrity sha512-fnKW9dGgmBfsebbYognQSv0CGGLFH1a5iV9EDYTBwmAQn+whbzHbLFlC+3XbHc8xaNtpO0etm8LOcRXs1qMRkQ== + emoji-regex@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" @@ -10053,7 +10068,7 @@ mimic-response@^4.0.0: resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-4.0.0.tgz#35468b19e7c75d10f5165ea25e75a5ceea7cf70f" integrity sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg== -mini-css-extract-plugin@^2.9.1: +mini-css-extract-plugin@^2.9.2: version "2.9.2" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.2.tgz#966031b468917a5446f4c24a80854b2947503c5b" integrity sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w== @@ -10159,6 +10174,11 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + nanoid@^3.3.7: version "3.3.8" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" @@ -10842,10 +10862,10 @@ postcss-custom-media@^11.0.6: "@csstools/css-tokenizer" "^3.0.4" "@csstools/media-query-list-parser" "^4.0.3" -postcss-custom-properties@^14.0.5: - version "14.0.5" - resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-14.0.5.tgz#a180444de695f6e11ee2390be93ff6537663e86c" - integrity sha512-UWf/vhMapZatv+zOuqlfLmYXeOhhHLh8U8HAKGI2VJ00xLRYoAJh4xv8iX6FB6+TLXeDnm0DBLMi00E0hodbQw== +postcss-custom-properties@^14.0.6: + version "14.0.6" + resolved "https://registry.yarnpkg.com/postcss-custom-properties/-/postcss-custom-properties-14.0.6.tgz#1af73a650bf115ba052cf915287c9982825fc90e" + integrity sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ== dependencies: "@csstools/cascade-layer-name-parser" "^2.0.5" "@csstools/css-parser-algorithms" "^3.0.5" @@ -10973,7 +10993,7 @@ postcss-load-config@^4.0.1: lilconfig "^3.0.0" yaml "^2.3.4" -postcss-loader@^7.3.3: +postcss-loader@^7.3.4: version "7.3.4" resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-7.3.4.tgz#aed9b79ce4ed7e9e89e56199d25ad1ec8f606209" integrity sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A== @@ -11082,12 +11102,12 @@ postcss-nested@^6.0.1: dependencies: postcss-selector-parser "^6.0.11" -postcss-nesting@^13.0.1: - version "13.0.1" - resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-13.0.1.tgz#c405796d7245a3e4c267a9956cacfe9670b5d43e" - integrity sha512-VbqqHkOBOt4Uu3G8Dm8n6lU5+9cJFxiuty9+4rcoyRPO9zZS1JIs6td49VIoix3qYqELHlJIn46Oih9SAKo+yQ== +postcss-nesting@^13.0.2: + version "13.0.2" + resolved "https://registry.yarnpkg.com/postcss-nesting/-/postcss-nesting-13.0.2.tgz#fde0d4df772b76d03b52eccc84372e8d1ca1402e" + integrity sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ== dependencies: - "@csstools/selector-resolve-nested" "^3.0.0" + "@csstools/selector-resolve-nested" "^3.1.0" "@csstools/selector-specificity" "^5.0.0" postcss-selector-parser "^7.0.0" @@ -11185,10 +11205,10 @@ postcss-place@^10.0.0: dependencies: postcss-value-parser "^4.2.0" -postcss-preset-env@^10.1.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-10.2.0.tgz#ea95a6fc70efb1a26f81e5cf0ccdb321a3439b6e" - integrity sha512-cl13sPBbSqo1Q7Ryb19oT5NZO5IHFolRbIMdgDq4f9w1MHYiL6uZS7uSsjXJ1KzRIcX5BMjEeyxmAevVXENa3Q== +postcss-preset-env@^10.2.1: + version "10.2.3" + resolved "https://registry.yarnpkg.com/postcss-preset-env/-/postcss-preset-env-10.2.3.tgz#3a84bde7205b48f1304a656b25841bd3f40fb3cb" + integrity sha512-zlQN1yYmA7lFeM1wzQI14z97mKoM8qGng+198w1+h6sCud/XxOjcKtApY9jWr7pXNS3yHDEafPlClSsWnkY8ow== dependencies: "@csstools/postcss-cascade-layers" "^5.0.1" "@csstools/postcss-color-function" "^4.0.10" @@ -11202,7 +11222,7 @@ postcss-preset-env@^10.1.0: "@csstools/postcss-hwb-function" "^4.0.10" "@csstools/postcss-ic-unit" "^4.0.2" "@csstools/postcss-initial" "^2.0.1" - "@csstools/postcss-is-pseudo-class" "^5.0.1" + "@csstools/postcss-is-pseudo-class" "^5.0.3" "@csstools/postcss-light-dark-function" "^2.0.9" "@csstools/postcss-logical-float-and-clear" "^3.0.0" "@csstools/postcss-logical-overflow" "^2.0.0" @@ -11224,7 +11244,7 @@ postcss-preset-env@^10.1.0: "@csstools/postcss-trigonometric-functions" "^4.0.9" "@csstools/postcss-unset-value" "^4.0.0" autoprefixer "^10.4.21" - browserslist "^4.24.5" + browserslist "^4.25.0" css-blank-pseudo "^7.0.1" css-has-pseudo "^7.0.2" css-prefers-color-scheme "^10.0.0" @@ -11235,7 +11255,7 @@ postcss-preset-env@^10.1.0: postcss-color-hex-alpha "^10.0.0" postcss-color-rebeccapurple "^10.0.0" postcss-custom-media "^11.0.6" - postcss-custom-properties "^14.0.5" + postcss-custom-properties "^14.0.6" postcss-custom-selectors "^8.0.5" postcss-dir-pseudo-class "^9.0.1" postcss-double-position-gradients "^6.0.2" @@ -11246,7 +11266,7 @@ postcss-preset-env@^10.1.0: postcss-image-set-function "^7.0.0" postcss-lab-function "^7.0.10" postcss-logical "^8.1.0" - postcss-nesting "^13.0.1" + postcss-nesting "^13.0.2" postcss-opacity-percentage "^3.0.0" postcss-overflow-shorthand "^6.0.0" postcss-page-break "^3.0.4" @@ -11344,7 +11364,7 @@ postcss-zindex@^6.0.2: resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-6.0.2.tgz#e498304b83a8b165755f53db40e2ea65a99b56e1" integrity sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg== -postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.24, postcss@^8.4.26, postcss@^8.4.31, postcss@^8.4.33, postcss@^8.4.38: +postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.24, postcss@^8.4.31, postcss@^8.4.33: version "8.4.38" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== @@ -11353,6 +11373,15 @@ postcss@^8.4.21, postcss@^8.4.23, postcss@^8.4.24, postcss@^8.4.26, postcss@^8.4 picocolors "^1.0.0" source-map-js "^1.2.0" +postcss@^8.5.4: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== + dependencies: + nanoid "^3.3.11" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postman-code-generators@^1.10.1: version "1.10.1" resolved "https://registry.yarnpkg.com/postman-code-generators/-/postman-code-generators-1.10.1.tgz#5d8d8500616b2bb0cac7417e923c36b2e73cbffe" @@ -12682,6 +12711,11 @@ sort-css-media-queries@2.2.0: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + source-map-support@~0.5.20: version "0.5.21" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" From 5da5ccda5ced787f28addbc88f087082e2fd1a39 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 25 Jun 2025 16:53:40 +0200 Subject: [PATCH 103/123] fix: correct user v2 api docs for v3 (#10112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved As documentation is published from the main branch and the releases get created from another branch, they are not always correctly equal. # How the Problems Are Solved Remove the unnecessary changes in the documentation for now, and create a second PR which can then be used to update the documentation. # Additional Changes Correct integration tests which also use the endpoints. # Additional Context Closes #10083 --------- Co-authored-by: Fabienne Bühler --- proto/zitadel/management.proto | 44 +- proto/zitadel/user/v2/user_service.proto | 544 ++++++++++------------- 2 files changed, 240 insertions(+), 348 deletions(-) diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 8cd0b22759..d633fbe8c5 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -493,8 +493,6 @@ service ManagementService { // Create User (Machine) // - // Deprecated: use [user service v2 CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a user of type machine instead. - // // Create a new user with the type machine for your API, service or device. These users are used for non-interactive authentication flows. rpc AddMachineUser(AddMachineUserRequest) returns (AddMachineUserResponse) { option (google.api.http) = { @@ -507,7 +505,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -693,8 +690,6 @@ service ManagementService { // Change user name // - // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the username of the user. Be aware that the user has to log in with the newly added username afterward rpc UpdateUserName(UpdateUserNameRequest) returns (UpdateUserNameResponse) { option (google.api.http) = { @@ -708,7 +703,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; - deprecated: true; responses: { key: "200" value: { @@ -1124,8 +1118,6 @@ service ManagementService { // Update User Phone (Human) // - // Deprecated: use [user service v2 SetPhone](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the phone number of a user. If the state is set to not verified, the user will get an SMS to verify (if a notification provider is configured). The phone number is only for informational purposes and to send messages, not for Authentication (2FA). rpc UpdateHumanPhone(UpdateHumanPhoneRequest) returns (UpdateHumanPhoneResponse) { option (google.api.http) = { @@ -1140,7 +1132,6 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "Users"; tags: "User Human"; - deprecated: true; responses: { key: "200" value: { @@ -1656,8 +1647,6 @@ service ManagementService { // Update Machine User // - // Deprecated: use [user service v2 UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type machine instead. - // // Change a service account/machine user. It is used for accounts with non-interactive authentication possibilities. rpc UpdateMachine(UpdateMachineRequest) returns (UpdateMachineResponse) { option (google.api.http) = { @@ -1670,7 +1659,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1692,8 +1680,6 @@ service ManagementService { // Create Secret for Machine User // - // Deprecated: use [user service v2 AddSecret](apis/resources/user_service_v2/user-service-add-secret.api.mdx) instead. - // // Create a new secret for a machine user/service account. It is used to authenticate the user (client credential grant). rpc GenerateMachineSecret(GenerateMachineSecretRequest) returns (GenerateMachineSecretResponse) { option (google.api.http) = { @@ -1706,7 +1692,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1728,8 +1713,6 @@ service ManagementService { // Delete Secret of Machine User // - // Deprecated: use [user service v2 RemoveSecret](apis/resources/user_service_v2/user-service-remove-secret.api.mdx) instead. - // // Delete a secret of a machine user/service account. The user will not be able to authenticate with the secret afterward. rpc RemoveMachineSecret(RemoveMachineSecretRequest) returns (RemoveMachineSecretResponse) { option (google.api.http) = { @@ -1741,7 +1724,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1763,8 +1745,6 @@ service ManagementService { // Get Machine user Key By ID // - // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. - // // Get a specific Key of a machine user by its id. Machine keys are used to authenticate with jwt profile authentication. rpc GetMachineKeyByIDs(GetMachineKeyByIDsRequest) returns (GetMachineKeyByIDsResponse) { option (google.api.http) = { @@ -1776,7 +1756,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1796,9 +1775,7 @@ service ManagementService { }; } - // Get Machine user Key By ID - // - // Deprecated: use [user service v2 ListKeys](apis/resources/user_service_v2/user-service-list-keys.api.mdx) instead. + // List Machine Keys // // Get the list of keys of a machine user. Machine keys are used to authenticate with jwt profile authentication. rpc ListMachineKeys(ListMachineKeysRequest) returns (ListMachineKeysResponse) { @@ -1812,7 +1789,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1834,8 +1810,6 @@ service ManagementService { // Create Key for machine user // - // Deprecated: use [user service v2 AddKey](apis/resources/user_service_v2/user-service-add-key.api.mdx) instead. - // // If a public key is not supplied, a new key is generated and will be returned in the response. // Make sure to store the returned key. // If an RSA public key is supplied, the private key is omitted from the response. @@ -1851,7 +1825,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1873,8 +1846,6 @@ service ManagementService { // Delete Key for machine user // - // Deprecated: use [user service v2 RemoveKey](apis/resources/user_service_v2/user-service-remove-key.api.mdx) instead. - // // Delete a specific key from a user. // The user will not be able to authenticate with that key afterward. rpc RemoveMachineKey(RemoveMachineKeyRequest) returns (RemoveMachineKeyResponse) { @@ -1887,7 +1858,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1909,8 +1879,6 @@ service ManagementService { // Get Personal-Access-Token (PAT) by ID // - // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. - // // Returns the PAT for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc GetPersonalAccessTokenByIDs(GetPersonalAccessTokenByIDsRequest) returns (GetPersonalAccessTokenByIDsResponse) { option (google.api.http) = { @@ -1922,7 +1890,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1944,8 +1911,6 @@ service ManagementService { // List Personal-Access-Tokens (PATs) // - // Deprecated: use [user service v2 ListPersonalAccessTokens](apis/resources/user_service_v2/user-service-list-personal-access-tokens.api.mdx) instead. - // // Returns a list of PATs for a user, currently only available for machine users/service accounts. PATs are ready-to-use tokens and can be sent directly in the authentication header. rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { option (google.api.http) = { @@ -1958,7 +1923,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -1980,8 +1944,6 @@ service ManagementService { // Create a Personal-Access-Token (PAT) // - // Deprecated: use [user service v2 AddPersonalAccessToken](apis/resources/user_service_v2/user-service-add-personal-access-token.api.mdx) instead. - // // Generates a new PAT for the user. Currently only available for machine users. // The token will be returned in the response, make sure to store it. // PATs are ready-to-use tokens and can be sent directly in the authentication header. @@ -1996,7 +1958,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { @@ -2018,8 +1979,6 @@ service ManagementService { // Remove a Personal-Access-Token (PAT) by ID // - // Deprecated: use [user service v2 RemovePersonalAccessToken](apis/resources/user_service_v2/user-service-remove-personal-access-token.api.mdx) instead. - // // Delete a PAT from a user. Afterward, the user will not be able to authenticate with that token anymore. rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { option (google.api.http) = { @@ -2031,7 +1990,6 @@ service ManagementService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; tags: "Users"; tags: "User Machine"; responses: { diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index a416555905..79f66266bc 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -133,13 +133,6 @@ service UserService { // Required permission: // - user.write rpc CreateUser (CreateUserRequest) returns (CreateUserResponse) { - option (google.api.http) = { - // The /new path segment does not follow Zitadels API design. - // The only reason why it is used here is to avoid a conflict with the ListUsers endpoint, which already handles POST /v2/users. - post: "/v2/users/new" - body: "*" - }; - option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { permission: "authenticated" @@ -169,8 +162,6 @@ service UserService { // Create a new human user // - // Deprecated: Use [CreateUser](apis/resources/user_service_v2/user-service-create-user.api.mdx) to create a new user of type human instead. - // // Create/import a new user with the type human. The newly created user will get a verification email if either the email address is not marked as verified and you did not request the verification to be returned. rpc AddHumanUser (AddHumanUserRequest) returns (AddHumanUserResponse) { option (google.api.http) = { @@ -189,7 +180,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -270,8 +260,6 @@ service UserService { // Change the user email // - // Deprecated: [Update the users email field](apis/resources/user_service_v2/user-service-update-user.api.mdx). - // // Change the email address of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by email.. rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) { option (google.api.http) = { @@ -286,7 +274,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -393,8 +380,6 @@ service UserService { // Set the user phone // - // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx). - // // Set the phone number of a user. If the state is set to not verified, a verification code will be generated, which can be either returned or sent to the user by sms.. rpc SetPhone(SetPhoneRequest) returns (SetPhoneResponse) { option (google.api.http) = { @@ -409,7 +394,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -427,8 +411,6 @@ service UserService { // Delete the user phone // - // Deprecated: [Update the users phone field](apis/resources/user_service_v2/user-service-update-user.api.mdx) to remove the phone number. - // // Delete the phone number of a user. rpc RemovePhone(RemovePhoneRequest) returns (RemovePhoneResponse) { option (google.api.http) = { @@ -443,7 +425,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -519,7 +500,6 @@ service UserService { }; } - // Update a User // // Partially update an existing user. @@ -529,10 +509,6 @@ service UserService { // Required permission: // - user.write rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) { - option (google.api.http) = { - patch: "/v2/users/{user_id}" - body: "*" - }; option (zitadel.protoc_gen_zitadel.v2.options) = { auth_option: { @@ -574,8 +550,6 @@ service UserService { // Update Human User // - // Deprecated: Use [UpdateUser](apis/resources/user_service_v2/user-service-update-user.api.mdx) to update a user of type human instead. - // // Update all information from a user.. rpc UpdateHumanUser(UpdateHumanUserRequest) returns (UpdateHumanUserResponse) { option (google.api.http) = { @@ -590,7 +564,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -1378,8 +1351,6 @@ service UserService { // Change password // - // Deprecated: [Update the users password](apis/resources/user_service_v2/user-service-update-user.api.mdx) instead. - // // Change the password of a user with either a verification code or the current password.. rpc SetPassword (SetPasswordRequest) returns (SetPasswordResponse) { option (google.api.http) = { @@ -1394,7 +1365,6 @@ service UserService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - deprecated: true; responses: { key: "200" value: { @@ -1410,6 +1380,245 @@ service UserService { }; } + + // Add a Users Secret + // + // Generates a client secret for the user. + // The client id is the users username. + // If the user already has a secret, it is overwritten. + // Only users of type machine can have a secret. + // + // Required permission: + // - user.write + rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was successfully generated."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Users Secret + // + // Remove the current client ID and client secret from a machine user. + // + // Required permission: + // - user.write + rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The secret was either successfully removed or it didn't exist in the first place."; + } + }; + }; + } + + // Add a Key + // + // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. + // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have keys. + // + // Required permission: + // - user.write + rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Key + // + // Remove a machine users key by the given key ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The key was either successfully removed or it not found in the first place."; + } + }; + }; + } + + // Search Keys + // + // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all machine user keys matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Add a Personal Access Token + // + // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. + // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. + // Only users of type machine can have personal access tokens. + // + // Required permission: + // - user.write + rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was successfully created."; + } + }; + responses: { + key: "404" + value: { + description: "The user ID does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Remove a Personal Access Token + // + // Removes a machine users personal access token by the given token ID and an optionally given user ID. + // + // Required permission: + // - user.write + rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The personal access token was either successfully removed or it was not found in the first place."; + } + }; + }; + } + + // Search Personal Access Tokens + // + // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. + // Make sure to include a limit and sorting for pagination. + // + // Required permission: + // - user.read + rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all personal access tokens matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + // List all possible authentication methods of a user // // List all possible authentication methods of a user like password, passwordless, (T)OTP and more.. @@ -1584,281 +1793,6 @@ service UserService { } }; } - - // Add a Users Secret - // - // Generates a client secret for the user. - // The client id is the users username. - // If the user already has a secret, it is overwritten. - // Only users of type machine can have a secret. - // - // Required permission: - // - user.write - rpc AddSecret(AddSecretRequest) returns (AddSecretResponse) { - option (google.api.http) = { - post: "/v2/users/{user_id}/secret" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The secret was successfully generated."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Users Secret - // - // Remove the current client ID and client secret from a machine user. - // - // Required permission: - // - user.write - rpc RemoveSecret(RemoveSecretRequest) returns (RemoveSecretResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/secret" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The secret was either successfully removed or it didn't exist in the first place."; - } - }; - }; - } - - // Add a Key - // - // Add a keys that can be used to securely authenticate at the Zitadel APIs using JWT profile authentication using short-lived tokens. - // Make sure you store the returned key safely, as you won't be able to read it from the Zitadel API anymore. - // Only users of type machine can have keys. - // - // Required permission: - // - user.write - rpc AddKey(AddKeyRequest) returns (AddKeyResponse) { - option (google.api.http) = { - post: "/v2/users/{user_id}/keys" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The key was successfully created."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Key - // - // Remove a machine users key by the given key ID and an optionally given user ID. - // - // Required permission: - // - user.write - rpc RemoveKey(RemoveKeyRequest) returns (RemoveKeyResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/keys/{key_id}" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The key was either successfully removed or it not found in the first place."; - } - }; - }; - } - - // Search Keys - // - // List all matching keys. By default all keys of the instance on which the caller has permission to read the owning users are returned. - // Make sure to include a limit and sorting for pagination. - // - // Required permission: - // - user.read - rpc ListKeys(ListKeysRequest) returns (ListKeysResponse) { - option (google.api.http) = { - post: "/v2/users/keys/search" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200"; - value: { - description: "A list of all machine user keys matching the query"; - }; - }; - responses: { - key: "400"; - value: { - description: "invalid list query"; - }; - }; - }; - } - - // Add a Personal Access Token - // - // Personal access tokens (PAT) are the easiest way to authenticate to the Zitadel APIs. - // Make sure you store the returned PAT safely, as you won't be able to read it from the Zitadel API anymore. - // Only users of type machine can have personal access tokens. - // - // Required permission: - // - user.write - rpc AddPersonalAccessToken(AddPersonalAccessTokenRequest) returns (AddPersonalAccessTokenResponse) { - option (google.api.http) = { - post: "/v2/users/{user_id}/pats" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The personal access token was successfully created."; - } - }; - responses: { - key: "404" - value: { - description: "The user ID does not exist."; - schema: { - json_schema: { - ref: "#/definitions/rpcStatus"; - }; - }; - } - }; - }; - } - - // Remove a Personal Access Token - // - // Removes a machine users personal access token by the given token ID and an optionally given user ID. - // - // Required permission: - // - user.write - rpc RemovePersonalAccessToken(RemovePersonalAccessTokenRequest) returns (RemovePersonalAccessTokenResponse) { - option (google.api.http) = { - delete: "/v2/users/{user_id}/pats/{token_id}" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200" - value: { - description: "The personal access token was either successfully removed or it was not found in the first place."; - } - }; - }; - } - - // Search Personal Access Tokens - // - // List all personal access tokens. By default all personal access tokens of the instance on which the caller has permission to read the owning users are returned. - // Make sure to include a limit and sorting for pagination. - // - // Required permission: - // - user.read - rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse) { - option (google.api.http) = { - post: "/v2/users/pats/search" - body: "*" - }; - - option (zitadel.protoc_gen_zitadel.v2.options) = { - auth_option: { - permission: "authenticated" - } - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - responses: { - key: "200"; - value: { - description: "A list of all personal access tokens matching the query"; - }; - }; - responses: { - key: "400"; - value: { - description: "invalid list query"; - }; - }; - }; - } } message AddHumanUserRequest{ From 27f88a639035ca83be099c7a35b2ebe48f486b32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Racedo?= Date: Wed, 25 Jun 2025 12:44:11 -0300 Subject: [PATCH 104/123] docs(migration): Added step-by-step guide for the Auth0 to Zitadel migration (#10118) Added a step-by-step guide for the Auth0 to Zitadel migration in preparation for the upcoming workshop. --- .../guides/migrate/sources/auth0-guide.md | 185 ++++++++++++++++++ docs/sidebars.js | 1 + 2 files changed, 186 insertions(+) create mode 100644 docs/docs/guides/migrate/sources/auth0-guide.md diff --git a/docs/docs/guides/migrate/sources/auth0-guide.md b/docs/docs/guides/migrate/sources/auth0-guide.md new file mode 100644 index 0000000000..f8a62b75fa --- /dev/null +++ b/docs/docs/guides/migrate/sources/auth0-guide.md @@ -0,0 +1,185 @@ +--- +title: Migrating Users from Auth0 to ZITADEL (Including Password Hashes) +sidebar_label: Auth0 Migration Guide +--- + +## 1. Introduction + +This guide will walk you through the steps to migrate users from Auth0 to ZITADEL, including password hashes (which requires Auth0's support assistance), so users don't need to reset their passwords. + +**What you'll learn with this guide** +- How to prepare your data from Auth0 +- Use of the ZITADEL migration tooling +- Performing the user import via ZITADEL's API +- Troubleshooting and validating the migration + +--- + +## 2. Prerequisites + +### 2.1. Install Go +The migration tool is written in Go. Download and install the latest version of Go from the [official Go website](https://go.dev/doc/install). + +### 2.2. Create a ZITADEL Instance and Organization +You'll need a target organization in ZITADEL to import your users. You can create a new organization or use an existing one. + +If you don't have a ZITADEL instance, you can [sign up for free here](https://zitadel.com) to create a new one for you. +See: [Managing Organizations in ZITADEL](https://zitadel.com/docs/guides/manage/console/organizations). + +> **Note:** Copy your Organization ID (Resource ID) since you will use the id in the later steps. + +--- + +## 3. Preparing Auth0 Data + +### 3.1. Export User Profiles and Password Hashes from Auth0 +You cannot bulk export user data from the Auth0 Dashboard. Instead, use the [Auth0 Management API](https://auth0.com/docs/manage-users/user-migration#bulk-user-exports) or the [User Import/Export extension](https://auth0.com/docs/manage-users/user-migration/user-import-export-extension). + +> **Important:** Password hashes cannot be obtained in a self-service way. +> You must open a **support ticket** with Auth0 and request a password hash export. +> If approved, Auth0 will provide an export containing the password hashes. + +Reference: [Export hashed passwords from Auth0](https://zitadel.com/docs/guides/migrate/sources/auth0#export-hashed-passwords) + +--- + +## 4. Running the ZITADEL Migration Tool + +### 4.1. Install the Migration Tool +Follow the installation instructions to set up the ZITADEL migration tool from [ZITADEL Tools](https://github.com/zitadel/zitadel-tools?tab=readme-ov-file#installation). + +### 4.2. Generate Import JSON +Use the migration tool to convert the Auth0 export file to a ZITADEL-compatible JSON. +Step-by-step instructions: [Migration Tool for Auth0](https://github.com/zitadel/zitadel-tools/blob/main/cmd/migration/auth0/readme.md) + +Typical steps: +- Run the migration tool with your exported Auth0 files as input. +- The tool generates a JSON file ready for import into ZITADEL. + +Example: +After obtaining the 2 required input files (passwords and profile) in JSON lines format, you can run the following command: + +Sample `passwords.ndjson` content, as obtained from the Auth0 Support team: +```json +{"_id":{"$oid":"emxdpVxozXeFb1HeEn5ThAK8"},"email_verified":true,"email":"tommie_krajcik85@hotmail.com","passwordHash":"$2b$10$d.GvZhGwTllA7OdAmsA75uGGzqr/mhdQoU88M3zD.fX3Vb8Rcf33.","password_set_date":{"$date":"2025-06-30T00:00:00.000Z"},"tenant":"test","connection":"Username-Password-Authentication","_tmp_is_unique":true} +``` + +Sample `profiles.json` content, as obtained from the Auth0 Management API: +```json +{"user_id":"auth0|emxdpVxozXeFb1HeEn5ThAK8","email_verified":true,"name":"Tommie Krajcik","email":"tommie_krajcik85@hotmail.com"} +``` + +Run the following command in your terminal (replace ORG_ID with your own organization ID): +```bash +zitadel-tools migrate auth0 --org= --users=./profiles.json --passwords=./passwords.ndjson --multiline --email-verified --output=./importBody.json --timeout=5m0s +``` + +The tool will merge both objects into a single one in the importBody.json output, this will be used in the next step to complete the import process. + +## 5. Importing Users into ZITADEL + +### 5.1. Obtain Access Token (or PAT) for API Access + +To call the ZITADEL Admin API, you need to authenticate using a **Service User** with the `IAM_OWNER` Manager permissions. + +There are two recommended authentication methods: + +- **Client Credentials Flow** + [Learn how to authenticate with client credentials.](https://zitadel.com/docs/guides/integrate/service-users/client-credentials) + +- **Personal Access Token (PAT)** + [Learn how to create and use a PAT.](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users#personal-access-token) + +**Reference:** [Service Users & API Authentication](https://zitadel.com/docs/guides/integrate/service-users/authenticate-service-users#authentication-methods) + +--- + +### 5.2. Import Data with the ZITADEL API + +- Use your **access token** or **PAT** to authenticate. +- Call the [Admin API – Import Data](https://zitadel.com/docs/apis/resources/admin/admin-service-import-data) endpoint, passing your generated JSON file. +- Verify that the users were imported successfully in the ZITADEL console. + +**Import Endpoint:** + +- `POST /admin/v1/import` +- `Authorization: Bearer ` +- **Body:** Generated in step 4.2 + +#### Example cURL request + +```bash +curl --location 'https:///admin/v1/import' \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +--header 'Authorization: Bearer ' \ +--data-raw '{ + "dataOrgs": { + "orgs": [ + { + "orgId": "", + "humanUsers": [ + { + "userId": "auth0|emxdpVxozXeFb1HeEn5ThAK8", + "user": { + "userName": "tommie_krajcik85@hotmail.com", + "profile": { + "firstName": "Tommie Krajcik", + "lastName": "Tommie Krajcik" + }, + "email": { + "email": "tommie_krajcik85@hotmail.com", + "isEmailVerified": true + }, + "hashedPassword": { + "value": "$2b$10$d.GvZhGwTllA7OdAmsA75uGGzqr/mhdQoU88M3zD.fX3Vb8Rcf33." + } + } + } + ] + } + ] + }, + "timeout": "5m0s" +}' +``` + +## 6. Testing the Migration + +### 6.1. Test User Login + +Use the **ZITADEL login page** or your integrated app to test logging in with one of the imported users. + +> **Password for the sample user:** `Password1!` + +Confirm that the migrated password works as expected. + +--- + +### 6.2. Troubleshooting + +**Common issues:** + +- Missing password hashes +- Malformed JSON +- Invalid or incomplete user data + +The import endpoint returns an `errors` array which can help you identify any issues with the import. + +#### Where to check logs and get help + +You can also verify that a user was imported by calling the **events endpoint** and checking for the following event type: + +```json +"user.human.added" +``` + +## 7. Q&A and Further Resources + +### Real-World Scenarios & Common Questions + +**Q:** What is the maximum number of users that can be imported in a single batch? +**A:** There is no hard limit on the number of users. However, there is a **timeout**. +For **ZITADEL Cloud deployments**, the timeout is **5 minutes**, which typically allows for importing around **5,000 users per batch**. + +--- \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index d7ebb80f5b..fe77ea0af2 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -203,6 +203,7 @@ module.exports = { items: [ "guides/migrate/sources/zitadel", "guides/migrate/sources/auth0", + "guides/migrate/sources/auth0-guide", "guides/migrate/sources/keycloak", ], }, From 1ebbe275b98d1b0918b808e1d7960df1ad638c27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 26 Jun 2025 11:08:37 +0300 Subject: [PATCH 105/123] chore(oidc): remove legacy storage methods (#10061) # Which Problems Are Solved Stabilize the optimized introspection code and cleanup unused code. # How the Problems Are Solved - `oidc_legacy_introspection` feature flag is removed and reserved. - `OPStorage` which are no longer needed have their bodies removed. - The method definitions need to remain in place so the interface remains implemented. - A panic is thrown in case any such method is still called # Additional Changes - A number of `OPStorage` methods related to token creation were already unused. These are also cleaned up. # Additional Context - Closes #10027 - #7822 --------- Co-authored-by: Livio Spring --- cmd/setup/config_test.go | 1 - cmd/start/config_test.go | 1 - .../components/features/features.component.ts | 1 - console/src/assets/i18n/bg.json | 2 - console/src/assets/i18n/cs.json | 2 - console/src/assets/i18n/de.json | 2 - console/src/assets/i18n/en.json | 2 - console/src/assets/i18n/es.json | 2 - console/src/assets/i18n/fr.json | 2 - console/src/assets/i18n/hu.json | 2 - console/src/assets/i18n/id.json | 2 - console/src/assets/i18n/it.json | 2 - console/src/assets/i18n/ja.json | 2 - console/src/assets/i18n/ko.json | 2 - console/src/assets/i18n/mk.json | 2 - console/src/assets/i18n/nl.json | 2 - console/src/assets/i18n/pl.json | 2 - console/src/assets/i18n/pt.json | 2 - console/src/assets/i18n/ro.json | 2 - console/src/assets/i18n/ru.json | 2 - console/src/assets/i18n/sv.json | 2 - console/src/assets/i18n/zh.json | 2 - docs/docs/apis/openidoauth/scopes.md | 8 +- internal/api/grpc/feature/v2/converter.go | 4 - .../api/grpc/feature/v2/converter_test.go | 20 - .../v2/integration_test/feature_test.go | 16 +- internal/api/grpc/feature/v2beta/converter.go | 4 - .../api/grpc/feature/v2beta/converter_test.go | 20 - .../v2beta/integration_test/feature_test.go | 9 - internal/api/oidc/auth_request.go | 49 +- internal/api/oidc/client.go | 832 +----------------- internal/api/oidc/client_credentials.go | 21 - internal/api/oidc/device_auth.go | 4 +- .../integration_test/token_exchange_test.go | 7 - .../oidc/integration_test/userinfo_test.go | 15 +- internal/api/oidc/introspect.go | 3 - internal/api/oidc/jwt-profile.go | 33 +- internal/api/oidc/userinfo.go | 3 - internal/command/instance_features.go | 2 - internal/command/instance_features_model.go | 5 - internal/command/instance_features_test.go | 32 +- internal/command/project_application_api.go | 31 - .../command/project_application_api_test.go | 101 --- internal/command/project_application_oidc.go | 31 - .../command/project_application_oidc_test.go | 170 ---- internal/command/system_features.go | 2 - internal/command/system_features_model.go | 5 - internal/command/system_features_test.go | 32 +- internal/feature/feature.go | 33 +- internal/feature/feature_test.go | 1 - internal/feature/key_enumer.go | 123 +-- internal/query/app.go | 92 -- internal/query/authn_key.go | 48 - internal/query/authn_key_test.go | 49 -- internal/query/instance_features.go | 1 - internal/query/instance_features_model.go | 7 +- internal/query/instance_features_test.go | 28 - .../query/projection/instance_features.go | 4 - .../projection/instance_features_test.go | 4 +- internal/query/projection/system_features.go | 4 - .../query/projection/system_features_test.go | 4 +- internal/query/system_features.go | 1 - internal/query/system_features_model.go | 6 +- internal/query/system_features_test.go | 24 - internal/query/user_grant.go | 8 - .../feature/feature_v2/eventstore.go | 2 - .../repository/feature/feature_v2/feature.go | 2 - proto/zitadel/feature/v2/instance.proto | 21 +- proto/zitadel/feature/v2/system.proto | 22 +- proto/zitadel/feature/v2beta/instance.proto | 21 +- proto/zitadel/feature/v2beta/system.proto | 22 +- 71 files changed, 143 insertions(+), 1884 deletions(-) diff --git a/cmd/setup/config_test.go b/cmd/setup/config_test.go index 8cf241cd6a..b147ed54a7 100644 --- a/cmd/setup/config_test.go +++ b/cmd/setup/config_test.go @@ -48,7 +48,6 @@ Actions: want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), - LegacyIntrospection: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(true), UserSchema: gu.Ptr(true), }) diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index 53c95d35ab..918fa51950 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -85,7 +85,6 @@ Actions: want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), - LegacyIntrospection: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(true), UserSchema: gu.Ptr(true), }) diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index d95bbdde43..70e038bae8 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -33,7 +33,6 @@ const FEATURE_KEYS = [ 'enableBackChannelLogout', // 'improvedPerformance', 'loginDefaultOrg', - 'oidcLegacyIntrospection', 'oidcSingleV1SessionTermination', 'oidcTokenExchange', 'oidcTriggerIntrospectionProjections', diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index dc3dc04193..7d594e8318 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1623,8 +1623,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Организация по подразбиране за влизане", "LOGINDEFAULTORG_DESCRIPTION": "Потребителският интерфейс за влизане ще използва настройките на организацията по подразбиране (а не на инстанцията), ако не е зададен контекст на организация.", - "OIDCLEGACYINTROSPECTION": "Наследено осмисляне OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "На скоро префакторизирахме крайния пункт за осмисляне заради производителностни причини. Тази функция може да се използва за връщане към наследената реализация, ако възникнат неочаквани грешки.", "OIDCTOKENEXCHANGE": "Обмяна на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Активиране на експерименталния тип на дарение urn:ietf:params:oauth:grant-type:token-exchange за краен пункт на токен OIDC. Обменът на токени може да се използва за заявка на токени с по-малък обхват или за имперсонализиране на други потребители. Вижте политиката за сигурност, за да разрешите имперсонализацията на инстанция.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Тригери за проекции на осмисляне на OIDC", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 6c85389c60..2ee5d9d0c5 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Výchozí organizace pro přihlášení", "LOGINDEFAULTORG_DESCRIPTION": "Přihlašovací rozhraní použije nastavení výchozí organizace (a ne z instance), pokud není nastaven žádný kontext organizace.", - "OIDCLEGACYINTROSPECTION": "Dědictví OIDC introspekce", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nedávno jsme přepracovali bod introspekce z výkonnostních důvodů. Tato funkce lze použít k rollbacku na dědickou implementaci, pokud se objeví neočekávané chyby.", "OIDCTOKENEXCHANGE": "Výměna tokenů OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Povolit experimentální typ udělení urn:ietf:params:oauth:grant-type:token-exchange pro bod tokenového bodu OIDC. Výměna tokenů lze použít k žádosti o tokeny s menším rozsahem nebo k impersonaci jiných uživatelů. Podívejte se na bezpečnostní politiku, abyste umožnili impersonaci na instanci.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Spouštěče projekcí introspekce OIDC", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index a1f449c1c2..b8f8363d13 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Standardorganisation für die Anmeldung", "LOGINDEFAULTORG_DESCRIPTION": "Die Anmelde-Benutzeroberfläche verwendet die Einstellungen der Standardorganisation (und nicht von der Instanz), wenn kein Organisationskontext festgelegt ist.", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy-Introspektion", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Wir haben kürzlich den Introspektionsendpunkt aus Leistungsgründen neu strukturiert. Mit diesem Feature können Sie zur alten Implementierung zurückkehren, falls unerwartete Fehler auftreten.", "OIDCTOKENEXCHANGE": "OIDC Token-Austausch", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivieren Sie den experimentellen urn:ietf:params:oauth:grant-type:token-exchange-Grant-Typ für den OIDC-Token-Endpunkt. Der Token-Austausch kann verwendet werden, um Token mit einem geringeren Umfang anzufordern oder andere Benutzer zu impersonieren. Siehe die Sicherheitsrichtlinie, um die Impersonation auf einer Instanz zu erlauben.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger-Introspektionsprojektionen", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 9e8dadd416..fe152acb81 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1627,8 +1627,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Login Default Org", "LOGINDEFAULTORG_DESCRIPTION": "The login UI will use the settings of the default org (and not from the instance) if no organization context is set", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy introspection", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Enable the experimental urn:ietf:params:oauth:grant-type:token-exchange grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index d5493f5d70..fff111fd1d 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1625,8 +1625,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organización predeterminada de inicio de sesión", "LOGINDEFAULTORG_DESCRIPTION": "La interfaz de inicio de sesión utilizará la configuración de la organización predeterminada (y no de la instancia) si no se establece ningún contexto de organización.", - "OIDCLEGACYINTROSPECTION": "Introspección heredada OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Recientemente hemos refactorizado el punto de introspección por razones de rendimiento. Esta función se puede utilizar para volver a la implementación heredada si surgen errores inesperados.", "OIDCTOKENEXCHANGE": "Intercambio de tokens OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita el tipo de concesión experimental urn:ietf:params:oauth:grant-type:token-exchange para el punto de extremo de token OIDC. El intercambio de tokens se puede utilizar para solicitar tokens con un alcance menor o suplantar a otros usuarios. Consulta la política de seguridad para permitir la suplantación en una instancia.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Desencadenadores de proyecciones de introspección OIDC", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index c0a17ac19d..fc5cf69602 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organisation par défaut de connexion", "LOGINDEFAULTORG_DESCRIPTION": "L'interface de connexion utilisera les paramètres de l'organisation par défaut (et non de l'instance) si aucun contexte d'organisation n'est défini.", - "OIDCLEGACYINTROSPECTION": "Introspection héritée OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nous avons récemment refondu le point d'introspection pour des raisons de performances. Cette fonctionnalité peut être utilisée pour revenir à l'implémentation héritée en cas de bogues inattendus.", "OIDCTOKENEXCHANGE": "Échange de jetons OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activez le type d'octroi expérimental urn:ietf:params:oauth:grant-type:token-exchange pour le point de terminaison de jeton OIDC. L'échange de jetons peut être utilisé pour demander des jetons avec une portée moindre ou pour usurper d'autres utilisateurs. Consultez la politique de sécurité pour autoriser l'usurpation sur une instance.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Déclencheurs de projections d'introspection OIDC", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index 2f208b82b9..d7dd32b15a 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1622,8 +1622,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Alapértelmezett Org bejelentkezés", "LOGINDEFAULTORG_DESCRIPTION": "A bejelentkezési felület az alapértelmezett org beállításait fogja használni (és nem az instance-tól), ha nincs megadva szervezeti kontextus", - "OIDCLEGACYINTROSPECTION": "OIDC régi introspekció", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Nemrég refaktoráltuk az introspekciós végpontot a teljesítmény javítása érdekében. Ezt a funkciót használhatod a régi implementációra való visszaállításhoz, ha váratlan hibák lépnének fel.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Engedélyezd a kísérleti urn:ietf:params:oauth:grant-type:token-exchange támogatását az OIDC token végpont számára. A token csere használható kisebb hatókörű tokenek kérésére vagy más felhasználók megszemélyesítésére. Tekintsd meg a biztonsági irányelvet az impersonáció engedélyezéséhez egy példányon.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Introspekciós Projekciók Indítása", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index d1d4001813..3dcd7b36b7 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1498,8 +1498,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Masuk Organisasi Default", "LOGINDEFAULTORG_DESCRIPTION": "UI login akan menggunakan pengaturan organisasi default (dan bukan dari instance) jika tidak ada konteks organisasi yang ditetapkan", - "OIDCLEGACYINTROSPECTION": "Introspeksi Warisan OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Kami baru-baru ini memfaktorkan ulang titik akhir introspeksi untuk alasan kinerja. Fitur ini dapat digunakan untuk melakukan rollback ke implementasi lama jika muncul bug yang tidak terduga.", "OIDCTOKENEXCHANGE": "Pertukaran Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktifkan jenis pemberian urn:ietf:params:oauth:grant-type:token-exchange eksperimental untuk titik akhir token OIDC. Pertukaran token dapat digunakan untuk meminta token dengan cakupan yang lebih kecil atau menyamar sebagai pengguna lain. Lihat kebijakan keamanan untuk mengizinkan peniruan identitas pada sebuah instans.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Memicu Proyeksi Introspeksi", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 2645afb801..da1df2ba38 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organizzazione predefinita per l'accesso", "LOGINDEFAULTORG_DESCRIPTION": "L'interfaccia di accesso utilizzerà le impostazioni dell'organizzazione predefinita (e non dell'istanza) se non è impostato alcun contesto organizzativo.", - "OIDCLEGACYINTROSPECTION": "Introspezione legacy OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Abbiamo recentemente ristrutturato il punto di introspezione per motivi di prestazioni. Questa funzionalità può essere utilizzata per tornare alla vecchia implementazione in caso di bug imprevisti.", "OIDCTOKENEXCHANGE": "Scambio token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Abilita il tipo di concessione sperimentale urn:ietf:params:oauth:grant-type:token-exchange per il punto finale del token OIDC. Lo scambio di token può essere utilizzato per richiedere token con uno scopo inferiore o impersonare altri utenti. Consultare la policy di sicurezza per consentire l'impersonificazione su un'istanza.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiezioni trigger OIDC per l'introspezione", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 95855ca5fe..1828a8ada8 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "ログイン時の既定組織", "LOGINDEFAULTORG_DESCRIPTION": "組織コンテキストが設定されていない場合、ログイン UI は既定の組織の設定を使用します (インスタンスの設定ではなく)", - "OIDCLEGACYINTROSPECTION": "OIDC レガシーイントロスペクション", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "パフォーマンス上の理由から最近イントロスペクション エンドポイントをリファクタリングしました。この機能は、予期しないバグが発生した場合にレガシー実装にロールバックするために使用できます。", "OIDCTOKENEXCHANGE": "OIDC トークン交換", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC トークン エンドポイント用に実験的な urn:ietf:params:oauth:grant-type:token-exchange 付与タイプを有効にします。トークン交換は、より少ないスコープを持つトークンを要求するか、他のユーザーになりすますために使用できます。インスタンスでのなりすましを許可するには、セキュリティポリシーを参照してください。", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC トリガーイントロスペクションプロジェクション", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index b51f14ff20..af5fa65972 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "로그인 기본 조직", "LOGINDEFAULTORG_DESCRIPTION": "조직 컨텍스트가 설정되지 않은 경우 로그인 UI가 기본 조직의 설정을 사용합니다 (인스턴스에서 설정되지 않음).", - "OIDCLEGACYINTROSPECTION": "OIDC 레거시 내부 조사", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "최근 내부 조사 엔드포인트를 성능을 위해 리팩토링했습니다. 예상치 못한 버그가 발생하면 이 기능을 사용하여 레거시 구현으로 롤백할 수 있습니다.", "OIDCTOKENEXCHANGE": "OIDC 토큰 교환", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC 토큰 엔드포인트의 실험적 urn:ietf:params:oauth:grant-type:token-exchange 허용을 활성화합니다. 토큰 교환을 통해 범위가 좁은 토큰을 요청하거나 다른 사용자를 가장할 수 있습니다. 인스턴스에서 가장을 허용하는 보안 정책을 확인하세요.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 트리거 내부 조사 프로젝션", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index c89a78238d..1e85e06928 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1625,8 +1625,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Најава Стандардна организација", "LOGINDEFAULTORG_DESCRIPTION": "Интерфејсот за најавување ќе ги користи поставките на стандардната организација (а не од примерот) ако не е поставен контекст на организацијата", - "OIDCLEGACYINTROSPECTION": "Интроспекција на наследството на OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Неодамна ја рефакториравме крајната точка на интроспекција поради перформанси. Оваа функција може да се користи за враќање на наследната имплементација доколку се појават неочекувани грешки.", "OIDCTOKENEXCHANGE": "Размена на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Овозможете го експерименталниот тип на грант urn:ietf:params:oauth:grant-type:token-exchange за крајната точка на токенот OIDC. Размената на токени може да се користи за барање токени со помал опсег или имитирање на други корисници. Погледнете ја безбедносната политика за да дозволите имитирање на пример.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции за интроспекција на активирањето на OIDC", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 17eb2241f7..c3de881784 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Standaard inlogorganisatie", "LOGINDEFAULTORG_DESCRIPTION": "Als er geen organisatiecontext is ingesteld, gebruikt de inlog-UI de instellingen van de standaardorganisatie (en niet van de instantie)", - "OIDCLEGACYINTROSPECTION": "Oude OIDC-introspectie", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "We hebben onlangs het introspectie-endpoint opnieuw gefactoreerd omwille van de prestaties. Deze functie kan worden gebruikt om terug te keren naar de oude implementatie als er onverwachte bugs optreden.", "OIDCTOKENEXCHANGE": "OIDC-tokenuitwisseling", "OIDCTOKENEXCHANGE_DESCRIPTION": "Schakel het experimentele type verlening urn:ietf:params:oauth:grant-type:token-exchange in voor het OIDC-tokenendpoint. Tokenuitwisseling kan worden gebruikt om tokens met een kleinere scope op te vragen of om zich voor te doen als andere gebruikers. Raadpleeg het beveiligingsbeleid om impersonation op een instantie toe te staan.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC-triggers voor introspectieprojecties", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index 33e5f5291d..ca5476463d 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1623,8 +1623,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Domyślna Organizacja Logowania", "LOGINDEFAULTORG_DESCRIPTION": "Jeśli nie ustawiono kontekstu organizacji, interfejs logowania będzie używać ustawień domyślnej organizacji (a nie instancji)", - "OIDCLEGACYINTROSPECTION": "Starsza Introspekcja OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Ostatnio przeprojektowaliśmy punkt końcowy introspekcji ze względów wydajnościowych. Ta funkcja może być używana do cofnięcia do starszej implementacji, jeśli wystąpią nieoczekiwane błędy.", "OIDCTOKENEXCHANGE": "Wymiana Tokenów OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Włącz eksperymentalny typ grantu urn:ietf:params:oauth:grant-type:token-exchange dla punktu końcowego tokena OIDC. Wymiana tokenów może być używana do żądania tokenów o mniejszym zakresie lub podszywania się za innych użytkowników. Aby zezwolić na podszywanie się na instancji, zapoznaj się z polityką bezpieczeństwa.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projekcje Introspekcji Wyzwalane przez OIDC", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index ccd17170e4..7c68a4cada 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1625,8 +1625,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organização Padrão de Login", "LOGINDEFAULTORG_DESCRIPTION": "A interface de login utilizará as configurações da organização padrão (e não da instância) se nenhum contexto de organização estiver definido", - "OIDCLEGACYINTROSPECTION": "Introspecção Legada OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Recentemente refatoramos o endpoint de introspecção por motivos de performance. Esse recurso pode ser usado para reverter para a implementação legada caso surjam bugs inesperados.", "OIDCTOKENEXCHANGE": "Troca de Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita o tipo de concessão experimental urn:ietf:params:oauth:grant-type:token-exchange para o endpoint de token OIDC. A troca de token pode ser usada para solicitar tokens com escopo menor ou personificar outros usuários. Consulte a política de segurança para permitir a personificação em uma instância.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projeções de Introspecção com Gatilho OIDC", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index e0f2e93045..0e0802a17c 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1622,8 +1622,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Organizație implicită de conectare", "LOGINDEFAULTORG_DESCRIPTION": "UI-ul de conectare va utiliza setările organizației implicite (și nu din instanță) dacă nu este setat niciun context de organizație", - "OIDCLEGACYINTROSPECTION": "Introspecție OIDC Legacy", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Am refactorizat recent endpointul de introspecție din motive de performanță. Această caracteristică poate fi utilizată pentru a reveni la implementarea legacy dacă apar erori neașteptate.", "OIDCTOKENEXCHANGE": "Schimb de token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activați tipul de grant experimental urn:ietf:params:oauth:grant-type:token-exchange pentru endpointul token OIDC. Schimbul de tokenuri poate fi utilizat pentru a solicita tokenuri cu o rază de acțiune mai mică sau pentru a impersona alți utilizatori. Consultați politica de securitate pentru a permite impersonarea pe o instanță.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiecții de introspecție OIDC Trigger", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 175e18688b..8e06568a82 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1677,8 +1677,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Организация по умолчанию для входа", "LOGINDEFAULTORG_DESCRIPTION": "Если контекст организации не установлен, пользовательский интерфейс входа будет использовать настройки организации по умолчанию (а не экземпляра)", - "OIDCLEGACYINTROSPECTION": "Устаревшая интроспекция OIDC", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Недавно мы переработали конечную точку интроспекции для повышения производительности. Эта функция может использоваться для отката к устаревшей реализации, если возникнут непредвиденные ошибки.", "OIDCTOKENEXCHANGE": "Обмен токенами OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Включите экспериментальный тип гранта urn:ietf:params:oauth:grant-type:token-exchange для конечной точки токена OIDC. Обмен токенами можно использовать для запроса токенов с меньшей областью действия или для impersonation (выдачи себя за) других пользователей. Информацию о разрешении impersonation на экземпляре см. в политике безопасности.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции интроспекции с триггером OIDC", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 9de5093353..1b80021a67 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1628,8 +1628,6 @@ "FEATURES": { "LOGINDEFAULTORG": "Standardorganisation för inloggning", "LOGINDEFAULTORG_DESCRIPTION": "Inloggningsgränssnittet kommer att använda inställningarna för standardorganisationen (och inte från instansen) om ingen organisationskontext är inställd", - "OIDCLEGACYINTROSPECTION": "OIDC Legacy introspection", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "Vi har nyligen omarbetat introspektionsändpunkten av prestandaskäl. Denna funktion kan användas för att återgå till den äldre implementationen om oväntade buggar uppstår.", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivera den experimentella urn:ietf:params:oauth:grant-type:token-exchange grant-typen för OIDC-tokenändpunkten. Tokenutbyte kan användas för att begära tokens med en mindre omfattning eller impersonera andra användare. Se säkerhetspolicyn för att tillåta impersonation på en instans.", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 4aa5ad0ef2..9565b61eca 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1624,8 +1624,6 @@ "FEATURES": { "LOGINDEFAULTORG": "登录默认组织", "LOGINDEFAULTORG_DESCRIPTION": "如果没有设置组织上下文,登录界面将使用默认组织的设置(而不是实例的设置)", - "OIDCLEGACYINTROSPECTION": "OIDC 传统内省", - "OIDCLEGACYINTROSPECTION_DESCRIPTION": "我们最近出于性能原因重构了内省端点。如果出现意外错误,可以使用此功能回滚到传统实现。", "OIDCTOKENEXCHANGE": "OIDC 令牌交换", "OIDCTOKENEXCHANGE_DESCRIPTION": "启用 OIDC 令牌端点的实验性 urn:ietf:params:oauth:grant-type:token-exchange 授权类型。令牌交换可用于请求具有较少范围的令牌或模拟其他用户。请参阅安全策略以允许在实例上模拟。", "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 触发内省投影", diff --git a/docs/docs/apis/openidoauth/scopes.md b/docs/docs/apis/openidoauth/scopes.md index 86f9769cab..d1fe9c7c5b 100644 --- a/docs/docs/apis/openidoauth/scopes.md +++ b/docs/docs/apis/openidoauth/scopes.md @@ -8,7 +8,7 @@ ZITADEL supports the usage of scopes as way of requesting information from the I ## Standard Scopes | Scopes | Description | -|:---------------|--------------------------------------------------------------------------------| +| :------------- | ------------------------------------------------------------------------------ | | openid | When using openid connect this is a mandatory scope | | profile | Optional scope to request the profile of the subject | | email | Optional scope to request the email of the subject | @@ -30,11 +30,9 @@ In addition to the standard compliant scopes we utilize the following scopes. | `urn:zitadel:iam:org:projects:roles` | `urn:zitadel:iam:org:projects:roles` | By using this scope a client can request the claim `urn:zitadel:iam:org:project:{projectid}:roles` to be asserted for each requested project. All projects of the token audience, requested by the `urn:zitadel:iam:org:project:id:{projectid}:aud` scopes will be used. | | `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. | | `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed | -| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles.[^1] | +| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles. | | `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access token | | `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project ID will be added to the audience of the access token | | `urn:zitadel:iam:user:metadata` | `urn:zitadel:iam:user:metadata` | By adding this scope, the metadata of the user will be included in the token. The values are base64 encoded. | -| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope: id, name and primary_domain of the resource owner (the users organization) will be included in the token. | +| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope: id, name and primary_domain of the resource owner (the users organization) will be included in the token. | | `urn:zitadel:iam:org:idp:id:{idp_id}` | `urn:zitadel:iam:org:idp:id:76625965177954913` | By adding this scope the user will directly be redirected to the identity provider to authenticate. Make sure you also send the primary domain scope if a custom login policy is configured. Otherwise the system will not be able to identify the identity provider. | - -[^1]: `urn:zitadel:iam:org:roles:id:{orgID}` is not supported when the `oidcLegacyIntrospection` [feature flag](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) is enabled. diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index e146ac2db6..56d3009457 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -20,7 +20,6 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command return &command.SystemFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -37,7 +36,6 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe Details: object.DomainToDetailsPb(f.Details), LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), @@ -57,7 +55,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com return &command.InstanceFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -77,7 +74,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat Details: object.DomainToDetailsPb(f.Details), LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index f481e4f65a..b77ed438f5 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -21,7 +21,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -34,7 +33,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { want := &command.SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -64,10 +62,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -114,10 +108,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_SYSTEM, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -160,7 +150,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -177,7 +166,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -211,10 +199,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -269,10 +253,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, diff --git a/internal/api/grpc/feature/v2/integration_test/feature_test.go b/internal/api/grpc/feature/v2/integration_test/feature_test.go index f27b57ff8c..fe09242429 100644 --- a/internal/api/grpc/feature/v2/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2/integration_test/feature_test.go @@ -209,7 +209,6 @@ func TestServer_GetSystemFeatures(t *testing.T) { require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) - assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } @@ -321,7 +320,7 @@ func TestServer_ResetInstanceFeatures(t *testing.T) { func TestServer_GetInstanceFeatures(t *testing.T) { _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ - OidcLegacyIntrospection: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }) require.NoError(t, err) t.Cleanup(func() { @@ -358,17 +357,13 @@ func TestServer_GetInstanceFeatures(t *testing.T) { }, want: &feature.GetInstanceFeaturesResponse{ LoginDefaultOrg: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, + Enabled: true, + Source: feature.Source_SOURCE_SYSTEM, }, OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: true, - Source: feature.Source_SOURCE_SYSTEM, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -427,10 +422,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: true, - Source: feature.Source_SOURCE_SYSTEM, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -456,7 +447,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) - assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 9739e1c4c8..406146fdbe 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -12,7 +12,6 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command. return &command.SystemFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -25,7 +24,6 @@ func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesRe Details: object.DomainToDetailsPb(f.Details), LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), @@ -37,7 +35,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm return &command.InstanceFeatures{ LoginDefaultOrg: req.LoginDefaultOrg, TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - LegacyIntrospection: req.OidcLegacyIntrospection, UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), @@ -52,7 +49,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat Details: object.DomainToDetailsPb(f.Details), LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - OidcLegacyIntrospection: featureSourceToFlagPb(&f.LegacyIntrospection), UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 72d91b10d4..2395574733 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -20,7 +20,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -29,7 +28,6 @@ func Test_systemFeaturesToCommand(t *testing.T) { want := &command.SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -54,10 +52,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -89,10 +83,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_SYSTEM, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -118,7 +108,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ LoginDefaultOrg: gu.Ptr(true), OidcTriggerIntrospectionProjections: gu.Ptr(false), - OidcLegacyIntrospection: nil, UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -128,7 +117,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { want := &command.InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: nil, UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, @@ -154,10 +142,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -193,10 +177,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, diff --git a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go index cbd9f5f939..549bc4ef0a 100644 --- a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go @@ -194,10 +194,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -256,10 +252,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcLegacyIntrospection: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -285,7 +277,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) - assertFeatureFlag(t, tt.want.OidcLegacyIntrospection, got.OidcLegacyIntrospection) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index da0e084877..b29e157fc2 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -24,7 +24,6 @@ import ( "github.com/zitadel/zitadel/internal/domain/federatedlogout" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/user/model" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -216,11 +215,11 @@ func (o *OPStorage) SaveAuthCode(ctx context.Context, id, code string) (err erro return o.repo.SaveAuthCode(ctx, id, code, userAgentID) } -func (o *OPStorage) DeleteAuthRequest(ctx context.Context, id string) (err error) { +func (o *OPStorage) DeleteAuthRequest(context.Context, string) error { panic(o.panicErr("DeleteAuthRequest")) } -func (o *OPStorage) CreateAccessToken(ctx context.Context, req op.TokenRequest) (string, time.Time, error) { +func (o *OPStorage) CreateAccessToken(context.Context, op.TokenRequest) (string, time.Time, error) { panic(o.panicErr("CreateAccessToken")) } @@ -492,34 +491,6 @@ func (o *OPStorage) GetRefreshTokenInfo(ctx context.Context, clientID string, to return refreshToken.UserID, refreshToken.ID, nil } -func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string, scopes []string) ([]string, error) { - for _, scope := range scopes { - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - return scopes, nil - } - } - - project, err := o.query.ProjectByOIDCClientID(ctx, clientID) - if err != nil { - return nil, zerrors.ThrowPreconditionFailed(nil, "OIDC-w4wIn", "Errors.Internal") - } - if !project.ProjectRoleAssertion { - return scopes, nil - } - projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(project.ID) - if err != nil { - return nil, zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") - } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) - if err != nil { - return nil, err - } - for _, role := range roles.ProjectRoles { - scopes = append(scopes, ScopeProjectRolePrefix+role.Key) - } - return scopes, nil -} - func (o *OPStorage) assertProjectRoleScopesByProject(ctx context.Context, project *query.Project, scopes []string) ([]string, error) { for _, scope := range scopes { if strings.HasPrefix(scope, ScopeProjectRolePrefix) { @@ -543,22 +514,6 @@ func (o *OPStorage) assertProjectRoleScopesByProject(ctx context.Context, projec return scopes, nil } -func (o *OPStorage) assertClientScopesForPAT(ctx context.Context, token *model.TokenView, clientID, projectID string) error { - token.Audience = append(token.Audience, clientID) - projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID) - if err != nil { - return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") - } - roles, err := o.query.SearchProjectRoles(ctx, true, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) - if err != nil { - return err - } - for _, role := range roles.ProjectRoles { - token.Scopes = append(token.Scopes, ScopeProjectRolePrefix+role.Key) - } - return nil -} - func setContextUserSystem(ctx context.Context) context.Context { data := authz.CtxData{ UserID: "SYSTEM", diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 08ed8c31b9..6a2639f213 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -2,25 +2,17 @@ package oidc import ( "context" - "encoding/base64" "encoding/json" - "fmt" "slices" "strings" "time" - "github.com/dop251/goja" "github.com/go-jose/go-jose/v4" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/actions" - "github.com/zitadel/zitadel/internal/actions/object" "github.com/zitadel/zitadel/internal/api/authz" - api_http "github.com/zitadel/zitadel/internal/api/http" - "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -44,6 +36,9 @@ const ( oidcCtx = "oidc" ) +// GetClientByClientID implements the op.Storage interface to retrieve an OIDC client by its ID. +// +// TODO: Still used for Auth request creation for v1 login. func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Client, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { @@ -57,819 +52,44 @@ func (o *OPStorage) GetClientByClientID(ctx context.Context, id string) (_ op.Cl return ClientFromBusiness(client, o.defaultLoginURL, o.defaultLoginURLV2), nil } -func (o *OPStorage) GetKeyByIDAndClientID(ctx context.Context, keyID, userID string) (_ *jose.JSONWebKey, err error) { - return o.GetKeyByIDAndIssuer(ctx, keyID, userID) +func (o *OPStorage) GetKeyByIDAndClientID(context.Context, string, string) (*jose.JSONWebKey, error) { + panic(o.panicErr("GetKeyByIDAndClientID")) } -func (o *OPStorage) GetKeyByIDAndIssuer(ctx context.Context, keyID, issuer string) (_ *jose.JSONWebKey, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - publicKeyData, err := o.query.GetAuthNKeyPublicKeyByIDAndIdentifier(ctx, keyID, issuer) - if err != nil { - return nil, err - } - publicKey, err := crypto.BytesToPublicKey(publicKeyData) - if err != nil { - return nil, err - } - return &jose.JSONWebKey{ - KeyID: keyID, - Use: "sig", - Key: publicKey, - }, nil +func (o *OPStorage) ValidateJWTProfileScopes(context.Context, string, []string) ([]string, error) { + panic(o.panicErr("ValidateJWTProfileScopes")) } -func (o *OPStorage) ValidateJWTProfileScopes(ctx context.Context, subject string, scopes []string) (_ []string, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - user, err := o.query.GetUserByID(ctx, true, subject) - if err != nil { - return nil, err - } - return o.checkOrgScopes(ctx, user, scopes) +func (o *OPStorage) AuthorizeClientIDSecret(context.Context, string, string) error { + panic(o.panicErr("AuthorizeClientIDSecret")) } -func (o *OPStorage) AuthorizeClientIDSecret(ctx context.Context, id string, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - ctx = authz.SetCtxData(ctx, authz.CtxData{ - UserID: oidcCtx, - OrgID: oidcCtx, - }) - app, err := o.query.AppByClientID(ctx, id) - if err != nil { - return err - } - if app.OIDCConfig != nil { - return o.command.VerifyOIDCClientSecret(ctx, app.ProjectID, app.ID, secret) - } - return o.command.VerifyAPIClientSecret(ctx, app.ProjectID, app.ID, secret) +func (o *OPStorage) SetUserinfoFromToken(context.Context, *oidc.UserInfo, string, string, string) error { + panic(o.panicErr("SetUserinfoFromToken")) } -func (o *OPStorage) SetUserinfoFromToken(ctx context.Context, userInfo *oidc.UserInfo, tokenID, subject, origin string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err - } - if err = o.isOriginAllowed(ctx, token.ClientID, origin); err != nil { - return err - } - return o.setUserinfo(ctx, userInfo, token.UserID, token.ClientID, token.Scope, nil) - } - - token, err := o.repo.TokenByIDs(ctx, subject, tokenID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") - } - if token.ApplicationID != "" { - if err = o.isOriginAllowed(ctx, token.ApplicationID, origin); err != nil { - return err - } - } - return o.setUserinfo(ctx, userInfo, token.UserID, token.ApplicationID, token.Scopes, nil) +func (o *OPStorage) SetUserinfoFromScopes(context.Context, *oidc.UserInfo, string, string, []string) error { + panic(o.panicErr("SetUserinfoFromScopes")) } -func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - if applicationID != "" { - app, err := o.query.AppByOIDCClientID(ctx, applicationID) - if err != nil { - return err - } - if app.OIDCConfig.AssertIDTokenRole { - scopes, err = o.assertProjectRoleScopes(ctx, applicationID, scopes) - if err != nil { - return zerrors.ThrowPreconditionFailed(err, "OIDC-Dfe2s", "Errors.Internal") - } - } - } - return o.setUserinfo(ctx, userInfo, userID, applicationID, scopes, nil) +func (o *OPStorage) SetUserinfoFromRequest(context.Context, *oidc.UserInfo, op.IDTokenRequest, []string) error { + panic(o.panicErr("SetUserinfoFromRequest")) } -// SetUserinfoFromRequest extends the SetUserinfoFromScopes during the id_token generation. -// This is required for V2 tokens to be able to set the sessionID (`sid`) claim. -func (o *OPStorage) SetUserinfoFromRequest(ctx context.Context, userinfo *oidc.UserInfo, request op.IDTokenRequest, _ []string) error { - switch t := request.(type) { - case *AuthRequestV2: - userinfo.AppendClaims("sid", t.SessionID) - case *RefreshTokenRequestV2: - userinfo.AppendClaims("sid", t.SessionID) - } - return nil +func (o *OPStorage) SetIntrospectionFromToken(context.Context, *oidc.IntrospectionResponse, string, string, string) error { + panic(o.panicErr("SetIntrospectionFromToken")) } -func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - if strings.HasPrefix(tokenID, command.IDPrefixV2) { - token, err := o.query.ActiveAccessTokenByToken(ctx, tokenID) - if err != nil { - return err - } - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") - } - return o.introspect(ctx, introspection, - tokenID, token.UserID, token.ClientID, clientID, projectID, - token.Audience, token.Scope, - token.AccessTokenCreation, token.AccessTokenExpiration) - } - - token, err := o.repo.TokenByIDs(ctx, subject, tokenID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Dsfb2", "token is not valid or has expired") - } - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID) - if err != nil { - return zerrors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") - } - if token.IsPAT { - err = o.assertClientScopesForPAT(ctx, token, clientID, projectID) - if err != nil { - return zerrors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal") - } - } - return o.introspect(ctx, introspection, - token.ID, token.UserID, token.ApplicationID, clientID, projectID, - token.Audience, token.Scopes, - token.CreationDate, token.Expiration) +func (o *OPStorage) ClientCredentialsTokenRequest(context.Context, string, []string) (op.TokenRequest, error) { + panic(o.panicErr("ClientCredentialsTokenRequest")) } -func (o *OPStorage) ClientCredentialsTokenRequest(ctx context.Context, clientID string, scope []string) (_ op.TokenRequest, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - user, err := o.query.GetUserByLoginName(ctx, false, clientID) - if err != nil { - return nil, err - } - scope, err = o.checkOrgScopes(ctx, user, scope) - if err != nil { - return nil, err - } - audience := domain.AddAudScopeToAudience(ctx, nil, scope) - return &clientCredentialsRequest{ - sub: user.ID, - scopes: scope, - audience: audience, - }, nil -} - -// ClientCredentials method is kept to keep the storage interface implemented. -// However, it should never be called as the VerifyClient method on the Server is overridden. func (o *OPStorage) ClientCredentials(context.Context, string, string) (op.Client, error) { - return nil, zerrors.ThrowInternal(nil, "OIDC-Su8So", "Errors.Internal") + panic(o.panicErr("ClientCredentials")) } -// isOriginAllowed checks whether a call by the client to the endpoint is allowed from the provided origin -// if no origin is provided, no error will be returned -func (o *OPStorage) isOriginAllowed(ctx context.Context, clientID, origin string) error { - if origin == "" { - return nil - } - app, err := o.query.AppByOIDCClientID(ctx, clientID) - if err != nil { - return err - } - if api_http.IsOriginAllowed(app.OIDCConfig.AllowedOrigins, origin) { - return nil - } - return zerrors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed") -} - -func (o *OPStorage) introspect( - ctx context.Context, - introspection *oidc.IntrospectionResponse, - tokenID, subject, tokenClientID, introspectionClientID, introspectionProjectID string, - audience, scope []string, - tokenCreation, tokenExpiration time.Time, -) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - for _, aud := range audience { - if aud == introspectionClientID || aud == introspectionProjectID { - userInfo := new(oidc.UserInfo) - err = o.setUserinfo(ctx, userInfo, subject, introspectionClientID, scope, []string{introspectionProjectID}) - if err != nil { - return err - } - introspection.SetUserInfo(userInfo) - introspection.Scope = scope - introspection.ClientID = tokenClientID - introspection.TokenType = oidc.BearerToken - introspection.Expiration = oidc.FromTime(tokenExpiration) - introspection.IssuedAt = oidc.FromTime(tokenCreation) - introspection.NotBefore = oidc.FromTime(tokenCreation) - introspection.Audience = audience - introspection.Issuer = op.IssuerFromContext(ctx) - introspection.JWTID = tokenID - return nil - } - } - return zerrors.ThrowPermissionDenied(nil, "OIDC-sdg3G", "token is not valid for this client") -} - -func (o *OPStorage) checkOrgScopes(ctx context.Context, user *query.User, scopes []string) ([]string, error) { - for i := len(scopes) - 1; i >= 0; i-- { - scope := scopes[i] - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - var orgID string - org, err := o.query.OrgByPrimaryDomain(ctx, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - if err == nil { - orgID = org.ID - } - if orgID != user.ResourceOwner { - scopes[i] = scopes[len(scopes)-1] - scopes[len(scopes)-1] = "" - scopes = scopes[:len(scopes)-1] - } - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - if strings.TrimPrefix(scope, domain.OrgIDScope) != user.ResourceOwner { - scopes[i] = scopes[len(scopes)-1] - scopes[len(scopes)-1] = "" - scopes = scopes[:len(scopes)-1] - } - } - } - return scopes, nil -} - -func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string, roleAudience []string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return err - } - if user.State != domain.UserStateActive { - return zerrors.ThrowUnauthenticated(nil, "OIDC-S3tha", "Errors.Users.NotActive") - } - var allRoles bool - roles := make([]string, 0) - for _, scope := range scopes { - switch scope { - case oidc.ScopeOpenID: - userInfo.Subject = user.ID - case oidc.ScopeEmail: - setUserInfoEmail(userInfo, user) - case oidc.ScopeProfile: - o.setUserInfoProfile(ctx, userInfo, user) - case oidc.ScopePhone: - setUserInfoPhone(userInfo, user) - case oidc.ScopeAddress: - //TODO: handle address for human users as soon as implemented - case ScopeUserMetaData: - if err := o.setUserInfoMetadata(ctx, userInfo, userID); err != nil { - return err - } - case ScopeResourceOwner: - if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil { - return err - } - case ScopeProjectsRoles: - allRoles = true - default: - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) - } - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - userInfo.AppendClaims(domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - userInfo.AppendClaims(domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope)) - if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil { - return err - } - } - } - } - - // if all roles are requested take the audience for those from the scopes - if allRoles && len(roleAudience) == 0 { - roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) - } - - userGrants, projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles, roleAudience) - if err != nil { - return err - } - o.setUserInfoRoleClaims(userInfo, projectRoles) - - return o.userinfoFlows(ctx, user, userGrants, userInfo) -} - -func (o *OPStorage) setUserInfoProfile(ctx context.Context, userInfo *oidc.UserInfo, user *query.User) { - userInfo.PreferredUsername = user.PreferredLoginName - userInfo.UpdatedAt = oidc.FromTime(user.ChangeDate) - if user.Machine != nil { - userInfo.Name = user.Machine.Name - return - } - userInfo.Name = user.Human.DisplayName - userInfo.FamilyName = user.Human.LastName - userInfo.GivenName = user.Human.FirstName - userInfo.Nickname = user.Human.NickName - userInfo.Gender = getGender(user.Human.Gender) - userInfo.Locale = oidc.NewLocale(user.Human.PreferredLanguage) - userInfo.Picture = domain.AvatarURL(o.assetAPIPrefix(ctx), user.ResourceOwner, user.Human.AvatarKey) -} - -func setUserInfoEmail(userInfo *oidc.UserInfo, user *query.User) { - if user.Human == nil { - return - } - userInfo.UserInfoEmail = oidc.UserInfoEmail{ - Email: string(user.Human.Email), - EmailVerified: oidc.Bool(user.Human.IsEmailVerified)} -} - -func setUserInfoPhone(userInfo *oidc.UserInfo, user *query.User) { - if user.Human == nil { - return - } - userInfo.UserInfoPhone = oidc.UserInfoPhone{ - PhoneNumber: string(user.Human.Phone), - PhoneNumberVerified: user.Human.IsPhoneVerified, - } -} - -func (o *OPStorage) setUserInfoMetadata(ctx context.Context, userInfo *oidc.UserInfo, userID string) error { - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return err - } - if len(userMetaData) > 0 { - userInfo.AppendClaims(ClaimUserMetaData, userMetaData) - } - return nil -} - -func (o *OPStorage) setUserInfoResourceOwner(ctx context.Context, userInfo *oidc.UserInfo, userID string) error { - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return err - } - for claim, value := range resourceOwnerClaims { - userInfo.AppendClaims(claim, value) - } - return nil -} - -func (o *OPStorage) setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) { - if roles != nil && len(roles.projects) > 0 { - if roles, ok := roles.projects[roles.requestProjectID]; ok { - userInfo.AppendClaims(ClaimProjectRoles, roles) - } - for projectID, roles := range roles.projects { - userInfo.AppendClaims(fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) - } - } -} - -func (o *OPStorage) userinfoFlows(ctx context.Context, user *query.User, userGrants *query.UserGrants, userInfo *oidc.UserInfo) error { - queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreUserinfoCreation, user.ResourceOwner) - if err != nil { - return err - } - - ctxFields := actions.SetContextFields( - actions.SetFields("v1", - actions.SetFields("claims", userinfoClaims(userInfo)), - actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} { - return func(call goja.FunctionCall) goja.Value { - return object.UserFromQuery(c, user) - } - }), - actions.SetFields("user", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner) - if err != nil { - logging.WithError(err).Debug("unable to create search query") - panic(err) - } - metadata, err := o.query.SearchUserMetadata( - ctx, - true, - userInfo.Subject, - &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get md in action") - panic(err) - } - return object.UserMetadataListFromQuery(c, metadata) - } - }), - actions.SetFields("grants", - func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) - }, - ), - ), - actions.SetFields("org", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) - } - }), - ), - ), - ) - - for _, action := range queriedActions { - actionCtx, cancel := context.WithTimeout(ctx, action.Timeout()) - claimLogs := []string{} - - apiFields := actions.WithAPIFields( - actions.SetFields("v1", - actions.SetFields("userinfo", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if userInfo.Claims[key] == nil { - userInfo.AppendClaims(key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("claims", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if userInfo.Claims[key] == nil { - userInfo.AppendClaims(key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("user", - actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 2 { - panic("exactly 2 (key, value) arguments expected") - } - key := call.Arguments[0].Export().(string) - val := call.Arguments[1].Export() - - value, err := json.Marshal(val) - if err != nil { - logging.WithError(err).Debug("unable to marshal") - panic(err) - } - - metadata := &domain.Metadata{ - Key: key, - Value: value, - } - if _, err = o.command.SetUserMetadata(ctx, metadata, userInfo.Subject, user.ResourceOwner); err != nil { - logging.WithError(err).Info("unable to set md in action") - panic(err) - } - return nil - }), - ), - ), - ) - - err = actions.Run( - actionCtx, - ctxFields, - apiFields, - action.Script, - action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., - ) - cancel() - if err != nil { - return err - } - if len(claimLogs) > 0 { - userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs) - } - } - - return nil -} - -func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - roles := make([]string, 0) - var allRoles bool - for _, scope := range scopes { - switch scope { - case ScopeUserMetaData: - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return nil, err - } - if len(userMetaData) > 0 { - claims = appendClaim(claims, ClaimUserMetaData, userMetaData) - } - case ScopeResourceOwner: - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return nil, err - } - for claim, value := range resourceOwnerClaims { - claims = appendClaim(claims, claim, value) - } - case ScopeProjectsRoles: - allRoles = true - } - if strings.HasPrefix(scope, ScopeProjectRolePrefix) { - roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) - } - if strings.HasPrefix(scope, domain.OrgDomainPrimaryScope) { - claims = appendClaim(claims, domain.OrgDomainPrimaryClaim, strings.TrimPrefix(scope, domain.OrgDomainPrimaryScope)) - } - if strings.HasPrefix(scope, domain.OrgIDScope) { - claims = appendClaim(claims, domain.OrgIDClaim, strings.TrimPrefix(scope, domain.OrgIDScope)) - resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) - if err != nil { - return nil, err - } - for claim, value := range resourceOwnerClaims { - claims = appendClaim(claims, claim, value) - } - } - } - - // If requested, use the audience as context for the roles, - // otherwise the project itself will be used - var roleAudience []string - if allRoles { - roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) - } - - userGrants, projectRoles, err := o.assertRoles(ctx, userID, clientID, roles, roleAudience) - if err != nil { - return nil, err - } - - if projectRoles != nil && len(projectRoles.projects) > 0 { - if roles, ok := projectRoles.projects[projectRoles.requestProjectID]; ok { - claims = appendClaim(claims, ClaimProjectRoles, roles) - } - for projectID, roles := range projectRoles.projects { - claims = appendClaim(claims, fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) - } - } - - return o.privateClaimsFlows(ctx, userID, userGrants, claims) -} - -func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userGrants *query.UserGrants, claims map[string]interface{}) (map[string]interface{}, error) { - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreAccessTokenCreation, user.ResourceOwner) - if err != nil { - return nil, err - } - - ctxFields := actions.SetContextFields( - actions.SetFields("v1", - actions.SetFields("claims", func(c *actions.FieldConfig) interface{} { - return c.Runtime.ToValue(claims) - }), - actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} { - return func(call goja.FunctionCall) goja.Value { - return object.UserFromQuery(c, user) - } - }), - actions.SetFields("user", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - resourceOwnerQuery, err := query.NewUserMetadataResourceOwnerSearchQuery(user.ResourceOwner) - if err != nil { - logging.WithError(err).Debug("unable to create search query") - panic(err) - } - metadata, err := o.query.SearchUserMetadata( - ctx, - true, - userID, - &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, - ) - if err != nil { - logging.WithError(err).Info("unable to get md in action") - panic(err) - } - return object.UserMetadataListFromQuery(c, metadata) - } - }), - actions.SetFields("grants", func(c *actions.FieldConfig) interface{} { - return object.UserGrantsFromQuery(ctx, o.query, c, userGrants) - }), - ), - actions.SetFields("org", - actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} { - return func(goja.FunctionCall) goja.Value { - return object.GetOrganizationMetadata(ctx, o.query, c, user.ResourceOwner) - } - }), - ), - ), - ) - - for _, action := range queriedActions { - claimLogs := []string{} - actionCtx, cancel := context.WithTimeout(ctx, action.Timeout()) - - apiFields := actions.WithAPIFields( - actions.SetFields("v1", - actions.SetFields("claims", - actions.SetFields("setClaim", func(key string, value interface{}) { - if strings.HasPrefix(key, ClaimPrefix) { - return - } - if _, ok := claims[key]; !ok { - claims = appendClaim(claims, key, value) - return - } - claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key)) - }), - actions.SetFields("appendLogIntoClaims", func(entry string) { - claimLogs = append(claimLogs, entry) - }), - ), - actions.SetFields("user", - actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value { - if len(call.Arguments) != 2 { - panic("exactly 2 (key, value) arguments expected") - } - key := call.Arguments[0].Export().(string) - val := call.Arguments[1].Export() - - value, err := json.Marshal(val) - if err != nil { - logging.WithError(err).Debug("unable to marshal") - panic(err) - } - - metadata := &domain.Metadata{ - Key: key, - Value: value, - } - if _, err = o.command.SetUserMetadata(ctx, metadata, userID, user.ResourceOwner); err != nil { - logging.WithError(err).Info("unable to set md in action") - panic(err) - } - return nil - }), - ), - ), - ) - - err = actions.Run( - actionCtx, - ctxFields, - apiFields, - action.Script, - action.Name, - append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))..., - ) - cancel() - if err != nil { - return nil, err - } - if len(claimLogs) > 0 { - claims = appendClaim(claims, fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs) - claimLogs = nil - } - } - - return claims, nil -} - -func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles, roleAudience []string) (*query.UserGrants, *projectsRoles, error) { - if (applicationID == "" || len(requestedRoles) == 0) && len(roleAudience) == 0 { - return nil, nil, nil - } - projectID, err := o.query.ProjectIDFromClientID(ctx, applicationID) - // applicationID might contain a username (e.g. client credentials) -> ignore the not found - if err != nil && !zerrors.IsNotFound(err) { - return nil, nil, err - } - // ensure the projectID of the requesting is part of the roleAudience - if projectID != "" { - roleAudience = append(roleAudience, projectID) - } - projectQuery, err := query.NewUserGrantProjectIDsSearchQuery(roleAudience) - if err != nil { - return nil, nil, err - } - userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID) - if err != nil { - return nil, nil, err - } - activeQuery, err := query.NewUserGrantStateQuery(domain.UserGrantStateActive) - if err != nil { - return nil, nil, err - } - grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{ - Queries: []query.SearchQuery{ - projectQuery, - userIDQuery, - activeQuery, - }, - }, true) - if err != nil { - return nil, nil, err - } - roles := new(projectsRoles) - // if specific roles where requested, check if they are granted and append them in the roles list - if len(requestedRoles) > 0 { - for _, requestedRole := range requestedRoles { - for _, grant := range grants.UserGrants { - checkGrantedRoles(roles, *grant, requestedRole, grant.ProjectID == projectID) - } - } - return grants, roles, nil - } - // no specific roles were requested, so convert any grants into roles - for _, grant := range grants.UserGrants { - for _, role := range grant.Roles { - roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID) - } - } - return grants, roles, nil -} - -func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) { - metaData, err := o.query.SearchUserMetadata(ctx, true, userID, &query.UserMetadataSearchQueries{}, false) - if err != nil { - return nil, err - } - - userMetaData := make(map[string]string) - for _, md := range metaData.Metadata { - userMetaData[md.Key] = base64.RawURLEncoding.EncodeToString(md.Value) - } - return userMetaData, nil -} - -func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string) (map[string]string, error) { - user, err := o.query.GetUserByID(ctx, true, userID) - if err != nil { - return nil, err - } - resourceOwner, err := o.query.OrgByID(ctx, true, user.ResourceOwner) - if err != nil { - return nil, err - } - return map[string]string{ - ClaimResourceOwnerID: resourceOwner.ID, - ClaimResourceOwnerName: resourceOwner.Name, - ClaimResourceOwnerPrimaryDomain: resourceOwner.Domain, - }, nil +func (o *OPStorage) GetPrivateClaimsFromScopes(context.Context, string, string, []string) (map[string]interface{}, error) { + panic(o.panicErr("GetPrivateClaimsFromScopes")) } func checkGrantedRoles(roles *projectsRoles, grant query.UserGrant, requestedRole string, isRequested bool) { @@ -946,14 +166,6 @@ func getGender(gender domain.Gender) oidc.Gender { return "" } -func appendClaim(claims map[string]interface{}, claim string, value interface{}) map[string]interface{} { - if claims == nil { - claims = make(map[string]interface{}) - } - claims[claim] = value - return claims -} - func userinfoClaims(userInfo *oidc.UserInfo) func(c *actions.FieldConfig) interface{} { return func(c *actions.FieldConfig) interface{} { marshalled, err := json.Marshal(userInfo) diff --git a/internal/api/oidc/client_credentials.go b/internal/api/oidc/client_credentials.go index 9087360452..c86f5da9ae 100644 --- a/internal/api/oidc/client_credentials.go +++ b/internal/api/oidc/client_credentials.go @@ -14,27 +14,6 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -type clientCredentialsRequest struct { - sub string - audience []string - scopes []string -} - -// GetSubject returns the subject for token to be created because of the client credentials request -// the subject will be the id of the service user -func (c *clientCredentialsRequest) GetSubject() string { - return c.sub -} - -// GetAudience returns the audience for token to be created because of the client credentials request -func (c *clientCredentialsRequest) GetAudience() []string { - return c.audience -} - -func (c *clientCredentialsRequest) GetScopes() []string { - return c.scopes -} - func (s *Server) clientCredentialsAuth(ctx context.Context, clientID, clientSecret string) (op.Client, error) { user, err := s.query.GetUserByLoginName(ctx, false, clientID) if zerrors.IsNotFound(err) { diff --git a/internal/api/oidc/device_auth.go b/internal/api/oidc/device_auth.go index a10cba499d..8912ad1736 100644 --- a/internal/api/oidc/device_auth.go +++ b/internal/api/oidc/device_auth.go @@ -88,6 +88,6 @@ func (o *OPStorage) StoreDeviceAuthorization(ctx context.Context, clientID, devi return err } -func (o *OPStorage) GetDeviceAuthorizatonState(ctx context.Context, _, deviceCode string) (state *op.DeviceAuthorizationState, err error) { - return nil, nil +func (o *OPStorage) GetDeviceAuthorizatonState(context.Context, string, string) (*op.DeviceAuthorizationState, error) { + panic(o.panicErr("GetDeviceAuthorizatonState")) } diff --git a/internal/api/oidc/integration_test/token_exchange_test.go b/internal/api/oidc/integration_test/token_exchange_test.go index dcd2d61669..56a50a7c96 100644 --- a/internal/api/oidc/integration_test/token_exchange_test.go +++ b/internal/api/oidc/integration_test/token_exchange_test.go @@ -52,13 +52,6 @@ func setTokenExchangeFeature(t *testing.T, instance *integration.Instance, value time.Sleep(time.Second) } -func resetFeatures(t *testing.T, instance *integration.Instance) { - iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - _, err := instance.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{}) - require.NoError(t, err) - time.Sleep(time.Second) -} - func setImpersonationPolicy(t *testing.T, instance *integration.Instance, value bool) { iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index d4aded0b48..bf201b242e 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -34,22 +34,11 @@ func TestServer_UserInfo(t *testing.T) { }) tests := []struct { name string - legacy bool trigger bool webKey bool }{ { - name: "legacy enabled", - legacy: true, - }, - { - name: "legacy disabled, trigger disabled", - legacy: false, - trigger: false, - }, - { - name: "legacy disabled, trigger enabled", - legacy: false, + name: "trigger enabled", trigger: true, }, @@ -59,7 +48,6 @@ func TestServer_UserInfo(t *testing.T) { // - By calling userinfo with the access token as JWT, the Token Verifier with the public key cache is tested. { name: "web keys", - legacy: false, trigger: false, webKey: true, }, @@ -68,7 +56,6 @@ func TestServer_UserInfo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ - OidcLegacyIntrospection: &tt.legacy, OidcTriggerIntrospectionProjections: &tt.trigger, WebKey: &tt.webKey, }) diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index c028013d6a..ee022eb3e9 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -25,9 +25,6 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR }() features := authz.GetFeatures(ctx) - if features.LegacyIntrospection { - return s.LegacyServer.Introspect(ctx, r) - } if features.TriggerIntrospectionProjections { query.TriggerIntrospectionProjections(ctx) } diff --git a/internal/api/oidc/jwt-profile.go b/internal/api/oidc/jwt-profile.go index fe668b5a8a..d230f58b5b 100644 --- a/internal/api/oidc/jwt-profile.go +++ b/internal/api/oidc/jwt-profile.go @@ -3,38 +3,9 @@ package oidc import ( "context" - "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - - "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) -func (o *OPStorage) JWTProfileTokenType(ctx context.Context, request op.TokenRequest) (_ op.AccessTokenType, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { - err = oidcError(err) - span.EndWithError(err) - }() - - mapJWTProfileScopesToAudience(ctx, request) - user, err := o.query.GetUserByID(ctx, false, request.GetSubject()) - if err != nil { - return 0, err - } - // the user should always be a machine, but let's just be sure - if user.Machine == nil { - return 0, zerrors.ThrowInvalidArgument(nil, "OIDC-jk26S", "invalid client type") - } - return accessTokenTypeToOIDC(user.Machine.AccessTokenType), nil -} - -func mapJWTProfileScopesToAudience(ctx context.Context, request op.TokenRequest) { - // the request should always be a JWTTokenRequest, but let's make sure - jwt, ok := request.(*oidc.JWTTokenRequest) - if !ok { - return - } - jwt.Audience = domain.AddAudScopeToAudience(ctx, jwt.Audience, jwt.Scopes) +func (o *OPStorage) JWTProfileTokenType(context.Context, op.TokenRequest) (op.AccessTokenType, error) { + panic(o.panicErr("JWTProfileTokenType")) } diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 61f03b6d0f..5266500e7a 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -35,9 +35,6 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques }() features := authz.GetFeatures(ctx) - if features.LegacyIntrospection { - return s.LegacyServer.UserInfo(ctx, r) - } if features.TriggerIntrospectionProjections { query.TriggerOIDCUserInfoProjections(ctx) } diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index cb12bff828..4d35d5a318 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -18,7 +18,6 @@ import ( type InstanceFeatures struct { LoginDefaultOrg *bool TriggerIntrospectionProjections *bool - LegacyIntrospection *bool UserSchema *bool TokenExchange *bool ImprovedPerformance []feature.ImprovedPerformanceType @@ -35,7 +34,6 @@ type InstanceFeatures struct { func (m *InstanceFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 977a46b6c2..399013aded 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -68,7 +68,6 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -98,9 +97,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyTriggerIntrospectionProjections: v := value.(bool) features.TriggerIntrospectionProjections = &v - case feature.KeyLegacyIntrospection: - v := value.(bool) - features.LegacyIntrospection = &v case feature.KeyTokenExchange: v := value.(bool) features.TokenExchange = &v @@ -141,7 +137,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.InstanceLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go index 02e8896a0c..8d0c7d5964 100644 --- a/internal/command/instance_features_test.go +++ b/internal/command/instance_features_test.go @@ -113,24 +113,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ResourceOwner: "instance1", }, }, - { - name: "set LegacyIntrospection", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - ), - ), - ), - args: args{ctx, &InstanceFeatures{ - LegacyIntrospection: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "instance1", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -156,12 +138,12 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { expectPushFailed(io.ErrClosedPipe, feature_v2.NewSetEvent[bool]( ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, + feature_v2.InstanceConsoleUseV2UserApi, true, ), ), ), args: args{ctx, &InstanceFeatures{ - LegacyIntrospection: gu.Ptr(true), + ConsoleUseV2UserApi: gu.Ptr(true), }}, wantErr: io.ErrClosedPipe, }, @@ -178,10 +160,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - ), feature_v2.NewSetEvent[bool]( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, true, @@ -195,7 +173,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { args: args{ctx, &InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), }}, @@ -224,10 +201,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, true, - )), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, false, @@ -247,7 +220,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { args: args{ctx, &InstanceFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index 5b8bbafcdf..2832dcf873 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -226,37 +226,6 @@ func (c *Commands) ChangeAPIApplicationSecret(ctx context.Context, projectID, ap return result, err } -func (c *Commands) VerifyAPIClientSecret(ctx context.Context, projectID, appID, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - app, err := c.getAPIAppWriteModel(ctx, projectID, appID, "") - if err != nil { - return err - } - if !app.State.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-DFnbf", "Errors.Project.App.NotExisting") - } - if !app.IsAPI() { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-Bf3fw", "Errors.Project.App.IsNotAPI") - } - if app.HashedSecret == "" { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D3t5g", "Errors.Project.App.APIConfigInvalid") - } - - projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") - updated, err := c.secretHasher.Verify(app.HashedSecret, secret) - spanPasswordComparison.EndWithError(err) - if err != nil { - return zerrors.ThrowInvalidArgument(err, "COMMAND-SADfg", "Errors.Project.App.ClientSecretInvalid") - } - if updated != "" { - c.apiUpdateSecret(ctx, projectAgg, app.AppID, updated) - } - return nil -} - func (c *Commands) APIUpdateSecret(ctx context.Context, appID, projectID, resourceOwner, updated string) { agg := project_repo.NewAggregate(projectID, resourceOwner) c.apiUpdateSecret(ctx, &agg.Aggregate, appID, updated) diff --git a/internal/command/project_application_api_test.go b/internal/command/project_application_api_test.go index 2702c00b39..a6d4349254 100644 --- a/internal/command/project_application_api_test.go +++ b/internal/command/project_application_api_test.go @@ -2,16 +2,11 @@ package command import ( "context" - "io" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/passwap" - "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -771,99 +766,3 @@ func newAPIAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner ) return event } - -func TestCommands_VerifyAPIClientSecret(t *testing.T) { - hasher := &crypto.Hasher{ - Swapper: passwap.NewSwapper(bcrypt.New(bcrypt.MinCost)), - } - hashedSecret, err := hasher.Hash("secret") - require.NoError(t, err) - agg := project.NewAggregate("projectID", "orgID") - - tests := []struct { - name string - secret string - eventstore func(*testing.T) *eventstore.Eventstore - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "app not exists", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-DFnbf", "Errors.Project.App.NotExisting"), - }, - { - name: "wrong app type", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-Bf3fw", "Errors.Project.App.IsNotAPI"), - }, - { - name: "no secret set", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", "", domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D3t5g", "Errors.Project.App.APIConfigInvalid"), - }, - { - name: "check succeeded", - secret: "secret", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", hashedSecret, domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - }, - { - name: "check failed", - secret: "wrong!", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewAPIConfigAddedEvent(context.Background(), &agg.Aggregate, "appID", "clientID", hashedSecret, domain.APIAuthMethodTypePrivateKeyJWT), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-SADfg", "Errors.Project.App.ClientSecretInvalid"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Commands{ - eventstore: tt.eventstore(t), - secretHasher: hasher, - } - err := c.VerifyAPIClientSecret(context.Background(), "projectID", "appID", tt.secret) - c.jobs.Wait() - require.ErrorIs(t, err, tt.wantErr) - }) - } -} diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 491bd38fca..77ef7ff0c7 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -322,37 +322,6 @@ func (c *Commands) ChangeOIDCApplicationSecret(ctx context.Context, projectID, a return result, err } -func (c *Commands) VerifyOIDCClientSecret(ctx context.Context, projectID, appID, secret string) (err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - app, err := c.getOIDCAppWriteModel(ctx, projectID, appID, "") - if err != nil { - return err - } - if !app.State.Exists() { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D8hba", "Errors.Project.App.NotExisting") - } - if !app.IsOIDC() { - return zerrors.ThrowInvalidArgument(nil, "COMMAND-BHgn2", "Errors.Project.App.IsNotOIDC") - } - if app.HashedSecret == "" { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-D6hba", "Errors.Project.App.OIDCConfigInvalid") - } - - projectAgg := ProjectAggregateFromWriteModel(&app.WriteModel) - ctx, spanPasswordComparison := tracing.NewNamedSpan(ctx, "passwap.Verify") - updated, err := c.secretHasher.Verify(app.HashedSecret, secret) - spanPasswordComparison.EndWithError(err) - if err != nil { - return zerrors.ThrowInvalidArgument(err, "COMMAND-Bz542", "Errors.Project.App.ClientSecretInvalid") - } - if updated != "" { - c.oidcUpdateSecret(ctx, projectAgg, appID, updated) - } - return nil -} - func (c *Commands) OIDCUpdateSecret(ctx context.Context, appID, projectID, resourceOwner, updated string) { agg := project_repo.NewAggregate(projectID, resourceOwner) c.oidcUpdateSecret(ctx, &agg.Aggregate, appID, updated) diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index 4b9f5bf94f..d0383b1b29 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -2,18 +2,13 @@ package command import ( "context" - "io" "testing" "time" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/zitadel/passwap" - "github.com/zitadel/passwap/bcrypt" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -1307,168 +1302,3 @@ func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner ) return event } - -func TestCommands_VerifyOIDCClientSecret(t *testing.T) { - hasher := &crypto.Hasher{ - Swapper: passwap.NewSwapper(bcrypt.New(bcrypt.MinCost)), - } - hashedSecret, err := hasher.Hash("secret") - require.NoError(t, err) - agg := project.NewAggregate("projectID", "orgID") - - tests := []struct { - name string - secret string - eventstore func(*testing.T) *eventstore.Eventstore - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "app not exists", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D8hba", "Errors.Project.App.NotExisting"), - }, - { - name: "wrong app type", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(nil, "COMMAND-BHgn2", "Errors.Project.App.IsNotOIDC"), - }, - { - name: "no secret set", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - "", - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - wantErr: zerrors.ThrowPreconditionFailed(nil, "COMMAND-D6hba", "Errors.Project.App.OIDCConfigInvalid"), - }, - { - name: "check succeeded", - secret: "secret", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - hashedSecret, - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - }, - { - name: "check failed", - secret: "wrong!", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - project.NewApplicationAddedEvent(context.Background(), &agg.Aggregate, "appID", "appName"), - ), - eventFromEventPusher( - project.NewOIDCConfigAddedEvent(context.Background(), - &agg.Aggregate, - domain.OIDCVersionV1, - "appID", - "client1@project", - hashedSecret, - []string{"https://test.ch"}, - []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - domain.OIDCApplicationTypeWeb, - domain.OIDCAuthMethodTypePost, - []string{"https://test.ch/logout"}, - true, - domain.OIDCTokenTypeBearer, - true, - true, - true, - time.Second*1, - []string{"https://sub.test.ch"}, - false, - "", - domain.LoginVersionUnspecified, - "", - ), - ), - ), - ), - wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-Bz542", "Errors.Project.App.ClientSecretInvalid"), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Commands{ - eventstore: tt.eventstore(t), - secretHasher: hasher, - } - err := c.VerifyOIDCClientSecret(context.Background(), "projectID", "appID", tt.secret) - c.jobs.Wait() - require.ErrorIs(t, err, tt.wantErr) - }) - } -} diff --git a/internal/command/system_features.go b/internal/command/system_features.go index b317ea93bb..f20c9f3cda 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -12,7 +12,6 @@ import ( type SystemFeatures struct { LoginDefaultOrg *bool TriggerIntrospectionProjections *bool - LegacyIntrospection *bool TokenExchange *bool UserSchema *bool ImprovedPerformance []feature.ImprovedPerformanceType @@ -26,7 +25,6 @@ type SystemFeatures struct { func (m *SystemFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && m.TriggerIntrospectionProjections == nil && - m.LegacyIntrospection == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index 28e56f8bd4..f1e6ba6357 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -61,7 +61,6 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, feature_v2.SystemTriggerIntrospectionProjectionsEventType, - feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -88,9 +87,6 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyTriggerIntrospectionProjections: v := value.(bool) features.TriggerIntrospectionProjections = &v - case feature.KeyLegacyIntrospection: - v := value.(bool) - features.LegacyIntrospection = &v case feature.KeyUserSchema: v := value.(bool) features.UserSchema = &v @@ -121,7 +117,6 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LegacyIntrospection, f.LegacyIntrospection, feature_v2.SystemLegacyIntrospectionEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType) diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go index b1b5207b8c..ff6aef8104 100644 --- a/internal/command/system_features_test.go +++ b/internal/command/system_features_test.go @@ -81,24 +81,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ResourceOwner: "SYSTEM", }, }, - { - name: "set LegacyIntrospection", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - ), - ), - ), - args: args{context.Background(), &SystemFeatures{ - LegacyIntrospection: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "SYSTEM", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -124,12 +106,12 @@ func TestCommands_SetSystemFeatures(t *testing.T) { expectPushFailed(io.ErrClosedPipe, feature_v2.NewSetEvent[bool]( context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, + feature_v2.SystemEnableBackChannelLogout, true, ), ), ), args: args{context.Background(), &SystemFeatures{ - LegacyIntrospection: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), }}, wantErr: io.ErrClosedPipe, }, @@ -146,10 +128,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -163,7 +141,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { args: args{context.Background(), &SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), }}, @@ -192,10 +169,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, true, - )), ), expectPush( feature_v2.NewSetEvent[bool]( @@ -219,7 +192,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { args: args{context.Background(), &SystemFeatures{ LoginDefaultOrg: gu.Ptr(true), TriggerIntrospectionProjections: gu.Ptr(false), - LegacyIntrospection: gu.Ptr(true), UserSchema: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(false), }}, diff --git a/internal/feature/feature.go b/internal/feature/feature.go index b5f5a901d4..2e32b6b122 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,22 +9,22 @@ import ( type Key int const ( - KeyUnspecified Key = iota - KeyLoginDefaultOrg - KeyTriggerIntrospectionProjections - KeyLegacyIntrospection - KeyUserSchema - KeyTokenExchange - KeyActionsDeprecated - KeyImprovedPerformance - KeyWebKey - KeyDebugOIDCParentError - KeyOIDCSingleV1SessionTermination - KeyDisableUserTokenEvent - KeyEnableBackChannelLogout - KeyLoginV2 - KeyPermissionCheckV2 - KeyConsoleUseV2UserApi + // Reserved: 3, 6 + + KeyUnspecified Key = 0 + KeyLoginDefaultOrg Key = 1 + KeyTriggerIntrospectionProjections Key = 2 + KeyUserSchema Key = 4 + KeyTokenExchange Key = 5 + KeyImprovedPerformance Key = 7 + KeyWebKey Key = 8 + KeyDebugOIDCParentError Key = 9 + KeyOIDCSingleV1SessionTermination Key = 10 + KeyDisableUserTokenEvent Key = 11 + KeyEnableBackChannelLogout Key = 12 + KeyLoginV2 Key = 13 + KeyPermissionCheckV2 Key = 14 + KeyConsoleUseV2UserApi Key = 15 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -43,7 +43,6 @@ const ( type Features struct { LoginDefaultOrg bool `json:"login_default_org,omitempty"` TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` - LegacyIntrospection bool `json:"legacy_introspection,omitempty"` UserSchema bool `json:"user_schema,omitempty"` TokenExchange bool `json:"token_exchange,omitempty"` ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` diff --git a/internal/feature/feature_test.go b/internal/feature/feature_test.go index 70e3ec9ffb..abb8968d6f 100644 --- a/internal/feature/feature_test.go +++ b/internal/feature/feature_test.go @@ -12,7 +12,6 @@ func TestKey(t *testing.T) { "unspecified", "login_default_org", "trigger_introspection_projections", - "legacy_introspection", } for _, want := range tests { t.Run(want, func(t *testing.T) { diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index a47b3eb4d9..e06199120a 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -7,17 +7,34 @@ import ( "strings" ) -const _KeyName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +const ( + _KeyName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" + _KeyLowerName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" + _KeyName_1 = "user_schematoken_exchange" + _KeyLowerName_1 = "user_schematoken_exchange" + _KeyName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" + _KeyLowerName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +) -var _KeyIndex = [...]uint16{0, 11, 28, 61, 81, 92, 106, 124, 144, 151, 174, 208, 232, 258, 266, 285, 308} - -const _KeyLowerName = "unspecifiedlogin_default_orgtrigger_introspection_projectionslegacy_introspectionuser_schematoken_exchangeactions_deprecatedimproved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" +var ( + _KeyIndex_0 = [...]uint8{0, 11, 28, 61} + _KeyIndex_1 = [...]uint8{0, 11, 25} + _KeyIndex_2 = [...]uint8{0, 20, 27, 50, 84, 108, 134, 142, 161, 184} +) func (i Key) String() string { - if i < 0 || i >= Key(len(_KeyIndex)-1) { + switch { + case 0 <= i && i <= 2: + return _KeyName_0[_KeyIndex_0[i]:_KeyIndex_0[i+1]] + case 4 <= i && i <= 5: + i -= 4 + return _KeyName_1[_KeyIndex_1[i]:_KeyIndex_1[i+1]] + case 7 <= i && i <= 15: + i -= 7 + return _KeyName_2[_KeyIndex_2[i]:_KeyIndex_2[i+1]] + default: return fmt.Sprintf("Key(%d)", i) } - return _KeyName[_KeyIndex[i]:_KeyIndex[i+1]] } // An "invalid array index" compiler error signifies that the constant values have changed. @@ -27,10 +44,8 @@ func _KeyNoOp() { _ = x[KeyUnspecified-(0)] _ = x[KeyLoginDefaultOrg-(1)] _ = x[KeyTriggerIntrospectionProjections-(2)] - _ = x[KeyLegacyIntrospection-(3)] _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] - _ = x[KeyActionsDeprecated-(6)] _ = x[KeyImprovedPerformance-(7)] _ = x[KeyWebKey-(8)] _ = x[KeyDebugOIDCParentError-(9)] @@ -42,60 +57,54 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyLegacyIntrospection, KeyUserSchema, KeyTokenExchange, KeyActionsDeprecated, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ - _KeyName[0:11]: KeyUnspecified, - _KeyLowerName[0:11]: KeyUnspecified, - _KeyName[11:28]: KeyLoginDefaultOrg, - _KeyLowerName[11:28]: KeyLoginDefaultOrg, - _KeyName[28:61]: KeyTriggerIntrospectionProjections, - _KeyLowerName[28:61]: KeyTriggerIntrospectionProjections, - _KeyName[61:81]: KeyLegacyIntrospection, - _KeyLowerName[61:81]: KeyLegacyIntrospection, - _KeyName[81:92]: KeyUserSchema, - _KeyLowerName[81:92]: KeyUserSchema, - _KeyName[92:106]: KeyTokenExchange, - _KeyLowerName[92:106]: KeyTokenExchange, - _KeyName[106:124]: KeyActionsDeprecated, - _KeyLowerName[106:124]: KeyActionsDeprecated, - _KeyName[124:144]: KeyImprovedPerformance, - _KeyLowerName[124:144]: KeyImprovedPerformance, - _KeyName[144:151]: KeyWebKey, - _KeyLowerName[144:151]: KeyWebKey, - _KeyName[151:174]: KeyDebugOIDCParentError, - _KeyLowerName[151:174]: KeyDebugOIDCParentError, - _KeyName[174:208]: KeyOIDCSingleV1SessionTermination, - _KeyLowerName[174:208]: KeyOIDCSingleV1SessionTermination, - _KeyName[208:232]: KeyDisableUserTokenEvent, - _KeyLowerName[208:232]: KeyDisableUserTokenEvent, - _KeyName[232:258]: KeyEnableBackChannelLogout, - _KeyLowerName[232:258]: KeyEnableBackChannelLogout, - _KeyName[258:266]: KeyLoginV2, - _KeyLowerName[258:266]: KeyLoginV2, - _KeyName[266:285]: KeyPermissionCheckV2, - _KeyLowerName[266:285]: KeyPermissionCheckV2, - _KeyName[285:308]: KeyConsoleUseV2UserApi, - _KeyLowerName[285:308]: KeyConsoleUseV2UserApi, + _KeyName_0[0:11]: KeyUnspecified, + _KeyLowerName_0[0:11]: KeyUnspecified, + _KeyName_0[11:28]: KeyLoginDefaultOrg, + _KeyLowerName_0[11:28]: KeyLoginDefaultOrg, + _KeyName_0[28:61]: KeyTriggerIntrospectionProjections, + _KeyLowerName_0[28:61]: KeyTriggerIntrospectionProjections, + _KeyName_1[0:11]: KeyUserSchema, + _KeyLowerName_1[0:11]: KeyUserSchema, + _KeyName_1[11:25]: KeyTokenExchange, + _KeyLowerName_1[11:25]: KeyTokenExchange, + _KeyName_2[0:20]: KeyImprovedPerformance, + _KeyLowerName_2[0:20]: KeyImprovedPerformance, + _KeyName_2[20:27]: KeyWebKey, + _KeyLowerName_2[20:27]: KeyWebKey, + _KeyName_2[27:50]: KeyDebugOIDCParentError, + _KeyLowerName_2[27:50]: KeyDebugOIDCParentError, + _KeyName_2[50:84]: KeyOIDCSingleV1SessionTermination, + _KeyLowerName_2[50:84]: KeyOIDCSingleV1SessionTermination, + _KeyName_2[84:108]: KeyDisableUserTokenEvent, + _KeyLowerName_2[84:108]: KeyDisableUserTokenEvent, + _KeyName_2[108:134]: KeyEnableBackChannelLogout, + _KeyLowerName_2[108:134]: KeyEnableBackChannelLogout, + _KeyName_2[134:142]: KeyLoginV2, + _KeyLowerName_2[134:142]: KeyLoginV2, + _KeyName_2[142:161]: KeyPermissionCheckV2, + _KeyLowerName_2[142:161]: KeyPermissionCheckV2, + _KeyName_2[161:184]: KeyConsoleUseV2UserApi, + _KeyLowerName_2[161:184]: KeyConsoleUseV2UserApi, } var _KeyNames = []string{ - _KeyName[0:11], - _KeyName[11:28], - _KeyName[28:61], - _KeyName[61:81], - _KeyName[81:92], - _KeyName[92:106], - _KeyName[106:124], - _KeyName[124:144], - _KeyName[144:151], - _KeyName[151:174], - _KeyName[174:208], - _KeyName[208:232], - _KeyName[232:258], - _KeyName[258:266], - _KeyName[266:285], - _KeyName[285:308], + _KeyName_0[0:11], + _KeyName_0[11:28], + _KeyName_0[28:61], + _KeyName_1[0:11], + _KeyName_1[11:25], + _KeyName_2[0:20], + _KeyName_2[20:27], + _KeyName_2[27:50], + _KeyName_2[50:84], + _KeyName_2[84:108], + _KeyName_2[108:134], + _KeyName_2[134:142], + _KeyName_2[142:161], + _KeyName_2[161:184], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/query/app.go b/internal/query/app.go index 5fed1e3ced..bc97c1807e 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -455,27 +455,6 @@ func (q *Queries) ProjectIDFromClientID(ctx context.Context, appID string) (id s return id, err } -func (q *Queries) ProjectByOIDCClientID(ctx context.Context, id string) (project *Project, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareProjectByOIDCAppQuery() - eq := sq.Eq{ - AppOIDCConfigColumnClientID.identifier(): id, - AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-XhJi4", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - project, err = scan(row) - return err - }, query, args...) - return project, err -} - func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -497,35 +476,6 @@ func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app * return app, err } -func (q *Queries) AppByClientID(ctx context.Context, clientID string) (app *App, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareAppQuery(true) - eq := sq.Eq{ - AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - AppColumnState.identifier(): domain.AppStateActive, - ProjectColumnState.identifier(): domain.ProjectStateActive, - OrgColumnState.identifier(): domain.OrgStateActive, - } - query, args, err := stmt.Where(sq.And{ - eq, - sq.Or{ - sq.Eq{AppOIDCConfigColumnClientID.identifier(): clientID}, - sq.Eq{AppAPIConfigColumnClientID.identifier(): clientID}, - }, - }).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Dfge2", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - app, err = scan(row) - return err - }, query, args...) - return app, err -} - func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, withOwnerRemoved bool) (apps *Apps, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -867,48 +817,6 @@ func prepareProjectIDByAppQuery() (sq.SelectBuilder, func(*sql.Row) (projectID s } } -func prepareProjectByOIDCAppQuery() (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { - return sq.Select( - ProjectColumnID.identifier(), - ProjectColumnCreationDate.identifier(), - ProjectColumnChangeDate.identifier(), - ProjectColumnResourceOwner.identifier(), - ProjectColumnState.identifier(), - ProjectColumnSequence.identifier(), - ProjectColumnName.identifier(), - ProjectColumnProjectRoleAssertion.identifier(), - ProjectColumnProjectRoleCheck.identifier(), - ProjectColumnHasProjectCheck.identifier(), - ProjectColumnPrivateLabelingSetting.identifier(), - ).From(projectsTable.identifier()). - Join(join(AppColumnProjectID, ProjectColumnID)). - Join(join(AppOIDCConfigColumnAppID, AppColumnID)). - PlaceholderFormat(sq.Dollar), - func(row *sql.Row) (*Project, error) { - p := new(Project) - err := row.Scan( - &p.ID, - &p.CreationDate, - &p.ChangeDate, - &p.ResourceOwner, - &p.State, - &p.Sequence, - &p.Name, - &p.ProjectRoleAssertion, - &p.ProjectRoleCheck, - &p.HasProjectCheck, - &p.PrivateLabelingSetting, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-yxTMh", "Errors.Project.NotFound") - } - return nil, zerrors.ThrowInternal(err, "QUERY-dj2FF", "Errors.Internal") - } - return p, nil - } -} - func prepareProjectByAppQuery() (sq.SelectBuilder, func(*sql.Row) (*Project, error)) { return sq.Select( ProjectColumnID.identifier(), diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index ffbe38e7ae..5a1f49d63c 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -254,34 +254,6 @@ func (q *Queries) GetAuthNKeyByID(ctx context.Context, shouldTriggerBulk bool, i return key, err } -func (q *Queries) GetAuthNKeyPublicKeyByIDAndIdentifier(ctx context.Context, id string, identifier string) (key []byte, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := prepareAuthNKeyPublicKeyQuery() - eq := sq.And{ - sq.Eq{ - AuthNKeyColumnID.identifier(): id, - AuthNKeyColumnIdentifier.identifier(): identifier, - AuthNKeyColumnEnabled.identifier(): true, - AuthNKeyColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - }, - sq.Gt{ - AuthNKeyColumnExpiration.identifier(): time.Now(), - }, - } - query, args, err := stmt.Where(eq).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-DAb32", "Errors.Query.SQLStatement") - } - - err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { - key, err = scan(row) - return err - }, query, args...) - return key, err -} - func NewAuthNKeyResourceOwnerQuery(id string) (SearchQuery, error) { return NewTextQuery(AuthNKeyColumnResourceOwner, id, TextEquals) } @@ -429,26 +401,6 @@ func prepareAuthNKeyQuery() (sq.SelectBuilder, func(row *sql.Row) (*AuthNKey, er } } -func prepareAuthNKeyPublicKeyQuery() (sq.SelectBuilder, func(row *sql.Row) ([]byte, error)) { - return sq.Select( - AuthNKeyColumnPublicKey.identifier(), - ).From(authNKeyTable.identifier()). - PlaceholderFormat(sq.Dollar), - func(row *sql.Row) ([]byte, error) { - var publicKey []byte - err := row.Scan( - &publicKey, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, zerrors.ThrowNotFound(err, "QUERY-SDf32", "Errors.AuthNKey.NotFound") - } - return nil, zerrors.ThrowInternal(err, "QUERY-Bfs2a", "Errors.Internal") - } - return publicKey, nil - } -} - func prepareAuthNKeysDataQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeysData, error)) { return sq.Select( AuthNKeyColumnID.identifier(), diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index b7c66cc665..ce45185363 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -423,55 +423,6 @@ func Test_AuthNKeyPrepares(t *testing.T) { }, object: (*AuthNKey)(nil), }, - { - name: "prepareAuthNKeyPublicKeyQuery no result", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQueriesScanErr( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: ([]byte)(nil), - }, - { - name: "prepareAuthNKeyPublicKeyQuery found", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQuery( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - prepareAuthNKeyPublicKeyCols, - []driver.Value{ - []byte("publicKey"), - }, - ), - }, - object: []byte("publicKey"), - }, - { - name: "prepareAuthNKeyPublicKeyQuery sql err", - prepare: prepareAuthNKeyPublicKeyQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(prepareAuthNKeyPublicKeyStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: ([]byte)(nil), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 4ec40dc9d5..501cfc4e9c 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -11,7 +11,6 @@ type InstanceFeatures struct { Details *domain.ObjectDetails LoginDefaultOrg FeatureSource[bool] TriggerIntrospectionProjections FeatureSource[bool] - LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 6a0abbb58c..7130044fbf 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -64,7 +64,6 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - feature_v2.InstanceLegacyIntrospectionEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -94,7 +93,6 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { } m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections - m.instance.LegacyIntrospection = m.system.LegacyIntrospection m.instance.UserSchema = m.system.UserSchema m.instance.TokenExchange = m.system.TokenExchange m.instance.ImprovedPerformance = m.system.ImprovedPerformance @@ -111,15 +109,12 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ return err } switch key { - case feature.KeyUnspecified, - feature.KeyActionsDeprecated: + case feature.KeyUnspecified: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) case feature.KeyTriggerIntrospectionProjections: features.TriggerIntrospectionProjections.set(level, event.Value) - case feature.KeyLegacyIntrospection: - features.LegacyIntrospection.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go index d80a3b05fc..af662e4898 100644 --- a/internal/query/instance_features_test.go +++ b/internal/query/instance_features_test.go @@ -75,10 +75,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, { @@ -97,10 +93,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -120,10 +112,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelInstance, Value: false, @@ -146,10 +134,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -177,10 +161,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -199,10 +179,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -230,10 +206,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 34100a0d66..443353c2e5 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -68,10 +68,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceTriggerIntrospectionProjectionsEventType, Reduce: reduceInstanceSetFeature[bool], }, - { - Event: feature_v2.InstanceLegacyIntrospectionEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceUserSchemaEventType, Reduce: reduceInstanceSetFeature[bool], diff --git a/internal/query/projection/instance_features_test.go b/internal/query/projection/instance_features_test.go index 4a4a46727f..703a0ce00a 100644 --- a/internal/query/projection/instance_features_test.go +++ b/internal/query/projection/instance_features_test.go @@ -26,7 +26,7 @@ func TestInstanceFeaturesProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - feature_v2.InstanceLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, feature_v2.AggregateType, []byte(`{"value": true}`), ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), @@ -41,7 +41,7 @@ func TestInstanceFeaturesProjection_reduces(t *testing.T) { expectedStmt: "INSERT INTO projections.instance_features2 (instance_id, key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (instance_id, key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.instance_features2.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", expectedArgs: []interface{}{ "agg-id", - "legacy_introspection", + "user_schema", anyArg{}, anyArg{}, uint64(15), diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index de54054e78..3f70f7dfa6 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -60,10 +60,6 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemTriggerIntrospectionProjectionsEventType, Reduce: reduceSystemSetFeature[bool], }, - { - Event: feature_v2.SystemLegacyIntrospectionEventType, - Reduce: reduceSystemSetFeature[bool], - }, { Event: feature_v2.SystemUserSchemaEventType, Reduce: reduceSystemSetFeature[bool], diff --git a/internal/query/projection/system_features_test.go b/internal/query/projection/system_features_test.go index 9bc19573cc..b64db7fb0a 100644 --- a/internal/query/projection/system_features_test.go +++ b/internal/query/projection/system_features_test.go @@ -24,7 +24,7 @@ func TestSystemFeaturesProjection_reduces(t *testing.T) { args: args{ event: getEvent( testEvent( - feature_v2.SystemLegacyIntrospectionEventType, + feature_v2.SystemUserSchemaEventType, feature_v2.AggregateType, []byte(`{"value": true}`), ), eventstore.GenericEventMapper[feature_v2.SetEvent[bool]]), @@ -38,7 +38,7 @@ func TestSystemFeaturesProjection_reduces(t *testing.T) { { expectedStmt: "INSERT INTO projections.system_features (key, creation_date, change_date, sequence, value) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (key) DO UPDATE SET (creation_date, change_date, sequence, value) = (projections.system_features.creation_date, EXCLUDED.change_date, EXCLUDED.sequence, EXCLUDED.value)", expectedArgs: []interface{}{ - "legacy_introspection", + "user_schema", anyArg{}, anyArg{}, uint64(15), diff --git a/internal/query/system_features.go b/internal/query/system_features.go index dcbbb7d6fe..8c340ce739 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -22,7 +22,6 @@ type SystemFeatures struct { LoginDefaultOrg FeatureSource[bool] TriggerIntrospectionProjections FeatureSource[bool] - LegacyIntrospection FeatureSource[bool] UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index 69e1f35968..f91bc7d1e9 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -57,7 +57,6 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, feature_v2.SystemTriggerIntrospectionProjectionsEventType, - feature_v2.SystemLegacyIntrospectionEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -81,15 +80,12 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S return err } switch key { - case feature.KeyUnspecified, - feature.KeyActionsDeprecated: + case feature.KeyUnspecified: return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) case feature.KeyTriggerIntrospectionProjections: features.TriggerIntrospectionProjections.set(level, event.Value) - case feature.KeyLegacyIntrospection: - features.LegacyIntrospection.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go index 5a58ac23d7..da59ceb549 100644 --- a/internal/query/system_features_test.go +++ b/internal/query/system_features_test.go @@ -53,10 +53,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -75,10 +71,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -97,10 +89,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -127,10 +115,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -149,10 +133,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemLegacyIntrospectionEventType, false, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -179,10 +159,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - LegacyIntrospection: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index ebd4ab7c0c..05d80fe381 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -78,14 +78,6 @@ func NewUserGrantProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantProjectID, id, TextEquals) } -func NewUserGrantProjectIDsSearchQuery(ids []string) (SearchQuery, error) { - list := make([]interface{}, len(ids)) - for i, value := range ids { - list[i] = value - } - return NewListQuery(UserGrantProjectID, list, ListIn) -} - func NewUserGrantProjectOwnerSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(ProjectColumnResourceOwner, id, TextEquals) } diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 00618f56c2..62fa568fca 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -9,7 +9,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, SystemLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) @@ -22,7 +21,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceLegacyIntrospectionEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index d5e8941df2..f75fae618b 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -14,7 +14,6 @@ var ( SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) - SystemLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLegacyIntrospection) SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) @@ -27,7 +26,6 @@ var ( InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) - InstanceLegacyIntrospectionEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLegacyIntrospection) InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index fe8d3f7a39..0455befb46 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -25,12 +25,6 @@ message SetInstanceFeaturesRequest{ description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -131,8 +125,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -148,13 +142,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index d222e2a90c..ac39e62f09 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetSystemFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -27,13 +27,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -105,8 +98,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -122,13 +115,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 7717dd7556..8028305fe4 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -25,12 +25,6 @@ message SetInstanceFeaturesRequest{ description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -97,8 +91,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -114,13 +108,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index 624e68ec79..95bf71da9b 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetSystemFeaturesRequest{ - reserved 6; - reserved "actions"; + reserved 3, 6; + reserved "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -27,13 +27,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_legacy_introspection = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -78,8 +71,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 7; - reserved "actions"; + reserved 4, 7; + reserved "oidc_legacy_introspection", "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -95,13 +88,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_legacy_introspection = 4 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "We have recently refactored the introspection endpoint for performance reasons. This feature can be used to rollback to the legacy implementation if unexpected bugs arise. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; From 016676e1dc21f031eb3819179f7c2827ad30ca3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Thu, 26 Jun 2025 19:17:45 +0300 Subject: [PATCH 106/123] chore(oidc): graduate webkey to stable (#10122) # Which Problems Are Solved Stabilize the usage of webkeys. # How the Problems Are Solved - Remove all legacy signing key code from the OIDC API - Remove the webkey feature flag from proto - Remove the webkey feature flag from console - Cleanup documentation # Additional Changes - Resolved some canonical header linter errors in OIDC - Use the constant for `projections.lock` in the saml package. # Additional Context - Closes #10029 - After #10105 - After #10061 --- cmd/start/start.go | 1 - .../components/features/features.component.ts | 1 - console/src/assets/i18n/bg.json | 2 - console/src/assets/i18n/cs.json | 2 - console/src/assets/i18n/de.json | 2 - console/src/assets/i18n/en.json | 2 - console/src/assets/i18n/es.json | 2 - console/src/assets/i18n/fr.json | 2 - console/src/assets/i18n/hu.json | 2 - console/src/assets/i18n/id.json | 2 - console/src/assets/i18n/it.json | 2 - console/src/assets/i18n/ja.json | 2 - console/src/assets/i18n/ko.json | 2 - console/src/assets/i18n/mk.json | 2 - console/src/assets/i18n/nl.json | 2 - console/src/assets/i18n/pl.json | 2 - console/src/assets/i18n/pt.json | 2 - console/src/assets/i18n/ro.json | 2 - console/src/assets/i18n/ru.json | 2 - console/src/assets/i18n/sv.json | 2 - console/src/assets/i18n/zh.json | 2 - .../guides/integrate/login/oidc/webkeys.md | 7 - internal/api/grpc/feature/v2/converter.go | 2 - .../api/grpc/feature/v2/converter_test.go | 10 - internal/api/grpc/feature/v2beta/converter.go | 2 - .../api/grpc/feature/v2beta/converter_test.go | 10 - .../webkey_integration_test.go | 62 +-- internal/api/grpc/webkey/v2beta/webkey.go | 21 - internal/api/oidc/access_token.go | 2 +- internal/api/oidc/auth_request_converter.go | 9 +- .../api/oidc/integration_test/keys_test.go | 41 +- .../oidc/integration_test/userinfo_test.go | 10 +- internal/api/oidc/key.go | 210 +------- internal/api/oidc/op.go | 12 +- internal/api/oidc/server.go | 2 +- internal/api/oidc/server_test.go | 89 ---- internal/api/oidc/token.go | 21 +- internal/api/saml/certificate.go | 3 +- .../eventstore/token_verifier.go | 29 +- internal/command/instance_features.go | 23 - internal/command/instance_features_model.go | 5 - internal/command/key_pair.go | 25 - internal/crypto/rsa.go | 8 - internal/feature/feature.go | 4 +- internal/feature/key_enumer.go | 65 +-- .../handlers/back_channel_logout.go | 18 +- .../handlers/mock/commands.mock.go | 113 ++--- .../handlers/mock/queries.mock.go | 137 +++--- .../notification/handlers/mock/queue.mock.go | 11 +- internal/notification/handlers/queries.go | 2 - internal/query/instance_features.go | 1 - internal/query/instance_features_model.go | 3 - internal/query/key.go | 317 ------------ internal/query/key_test.go | 453 ------------------ .../query/projection/instance_features.go | 4 - .../feature/feature_v2/eventstore.go | 1 - .../repository/feature/feature_v2/feature.go | 1 - proto/zitadel/feature/v2/instance.proto | 22 +- proto/zitadel/feature/v2beta/instance.proto | 22 +- 59 files changed, 203 insertions(+), 1614 deletions(-) delete mode 100644 internal/query/key_test.go diff --git a/cmd/start/start.go b/cmd/start/start.go index 8820480f0c..3c3b5cb3e0 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -550,7 +550,6 @@ func startAPIs( keys.OIDC, keys.OIDCKey, eventstore, - dbClient, userAgentInterceptor, instanceInterceptor.Handler, limitingAccessInterceptor, diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index 70e038bae8..ace2788fcf 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -38,7 +38,6 @@ const FEATURE_KEYS = [ 'oidcTriggerIntrospectionProjections', 'permissionCheckV2', 'userSchema', - 'webKey', ] as const; export type ToggleState = { source: Source; enabled: boolean }; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 7d594e8318..50c0d66027 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1641,8 +1641,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout имплементира OpenID Connect Back-Channel Logout 1.0 и може да се използва за уведомяване на клиентите за прекратяване на сесията при OpenID доставчика.", "PERMISSIONCHECKV2": "Проверка на разрешения V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ако флагът е активиран, ще можете да използвате новия API и неговите функции.", - "WEBKEY": "Уеб ключ", - "WEBKEY_DESCRIPTION": "Ако флагът е активиран, ще можете да използвате новия API и неговите функции.", "STATES": { "INHERITED": "Наследено", "ENABLED": "Активирано", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 2ee5d9d0c5..5b4547ccb4 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementuje OpenID Connect Back-Channel Logout 1.0 a může být použit k informování klientů o ukončení relace u poskytovatele OpenID.", "PERMISSIONCHECKV2": "Kontrola oprávnění V2", "PERMISSIONCHECKV2_DESCRIPTION": "Pokud je příznak povolen, budete moci používat nový API a jeho funkce.", - "WEBKEY": "Webový klíč", - "WEBKEY_DESCRIPTION": "Pokud je příznak povolen, budete moci používat nový API a jeho funkce.", "STATES": { "INHERITED": "Děděno", "ENABLED": "Povoleno", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index b8f8363d13..8fec6498ec 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Der Back-Channel-Logout implementiert OpenID Connect Back-Channel Logout 1.0 und kann verwendet werden, um Clients über die Beendigung der Sitzung beim OpenID-Provider zu benachrichtigen.", "PERMISSIONCHECKV2": "Berechtigungsprüfung V2", "PERMISSIONCHECKV2_DESCRIPTION": "Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", - "WEBKEY": "Web-Schlüssel", - "WEBKEY_DESCRIPTION": "Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", "STATES": { "INHERITED": "Erben", "ENABLED": "Aktiviert", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index fe152acb81..95fd55bfef 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1645,8 +1645,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "The Back-Channel Logout implements OpenID Connect Back-Channel Logout 1.0 and can be used to notify clients about session termination at the OpenID Provider.", "PERMISSIONCHECKV2": "Permission Check V2", "PERMISSIONCHECKV2_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", - "WEBKEY": "Web Key", - "WEBKEY_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", "STATES": { "INHERITED": "Inherit", "ENABLED": "Enabled", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index fff111fd1d..359aa4a0b5 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1643,8 +1643,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "El Back-Channel Logout implementa OpenID Connect Back-Channel Logout 1.0 y se puede usar para notificar a los clientes sobre la terminación de la sesión en el proveedor de OpenID.", "PERMISSIONCHECKV2": "Verificación de permisos V2", "PERMISSIONCHECKV2_DESCRIPTION": "Si la bandera está habilitada, podrá usar la nueva API y sus funciones.", - "WEBKEY": "Clave web", - "WEBKEY_DESCRIPTION": "Si la bandera está habilitada, podrá usar la nueva API y sus funciones.", "STATES": { "INHERITED": "Heredado", "ENABLED": "Habilitado", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index fc5cf69602..0864a2f8c0 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Le Back-Channel Logout implémente OpenID Connect Back-Channel Logout 1.0 et peut être utilisé pour notifier les clients de la fin de session chez le fournisseur OpenID.", "PERMISSIONCHECKV2": "Vérification des permissions V2", "PERMISSIONCHECKV2_DESCRIPTION": "Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", - "WEBKEY": "Clé web", - "WEBKEY_DESCRIPTION": "Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", "STATES": { "INHERITED": "Hérité", "ENABLED": "Activé", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index d7dd32b15a..a87122dc52 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1640,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "A Back-Channel Logout megvalósítja az OpenID Connect Back-Channel Logout 1.0-t, és használható az ügyfelek értesítésére a munkamenet befejezéséről az OpenID szolgáltatónál.", "PERMISSIONCHECKV2": "Engedély ellenőrzés V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ha a zászló engedélyezve van, használhatja az új API-t és annak funkcióit.", - "WEBKEY": "Webkulcs", - "WEBKEY_DESCRIPTION": "Ha a zászló engedélyezve van, használhatja az új API-t és annak funkcióit.", "STATES": { "INHERITED": "Örököl", "ENABLED": "Engedélyezve", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 3dcd7b36b7..3f245d03c5 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1513,8 +1513,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "The Back-Channel Logout implements OpenID Connect Back-Channel Logout 1.0 and can be used to notify clients about session termination at the OpenID Provider.", "PERMISSIONCHECKV2": "Permission Check V2", "PERMISSIONCHECKV2_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", - "WEBKEY": "Web Key", - "WEBKEY_DESCRIPTION": "If the flag is enabled, you'll be able to use the new API and its features.", "STATES": { "INHERITED": "Mewarisi", "ENABLED": "Diaktifkan", "DISABLED": "Dengan disabilitas" }, "INHERITED_DESCRIPTION": "Ini menetapkan nilai ke nilai default sistem.", "INHERITEDINDICATOR_DESCRIPTION": { diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index da1df2ba38..e127281433 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Il Back-Channel Logout implementa OpenID Connect Back-Channel Logout 1.0 e può essere utilizzato per notificare ai client la terminazione della sessione presso il provider OpenID.", "PERMISSIONCHECKV2": "Controllo permessi V2", "PERMISSIONCHECKV2_DESCRIPTION": "Se il flag è abilitato, potrai utilizzare la nuova API e le sue funzionalità.", - "WEBKEY": "Chiave Web", - "WEBKEY_DESCRIPTION": "Se il flag è abilitato, potrai utilizzare la nuova API e le sue funzionalità.", "STATES": { "INHERITED": "Predefinito", "ENABLED": "Abilitato", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 1828a8ada8..250561e938 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "バックチャネルログアウトは OpenID Connect バックチャネルログアウト 1.0 を実装し、OpenID プロバイダーでのセッション終了についてクライアントに通知するために使用できます。", "PERMISSIONCHECKV2": "権限チェック V2", "PERMISSIONCHECKV2_DESCRIPTION": "フラグが有効になっている場合、新しい API とその機能を使用できます。", - "WEBKEY": "ウェブキー", - "WEBKEY_DESCRIPTION": "フラグが有効になっている場合、新しい API とその機能を使用できます。", "STATES": { "INHERITED": "継承", "ENABLED": "有効", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index af5fa65972..716375941d 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "백채널 로그아웃은 OpenID Connect 백채널 로그아웃 1.0을 구현하며, OpenID 제공자에서 세션 종료에 대해 클라이언트에게 알리는 데 사용할 수 있습니다.", "PERMISSIONCHECKV2": "권한 확인 V2", "PERMISSIONCHECKV2_DESCRIPTION": "플래그가 활성화되면 새로운 API와 그 기능을 사용할 수 있습니다.", - "WEBKEY": "웹 키", - "WEBKEY_DESCRIPTION": "플래그가 활성화되면 새로운 API와 그 기능을 사용할 수 있습니다.", "STATES": { "INHERITED": "상속", "ENABLED": "활성화됨", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 1e85e06928..39836f5dfc 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1643,8 +1643,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout имплементира OpenID Connect Back-Channel Logout 1.0 и може да се користи за известување на клиентите за завршување на сесијата кај OpenID провајдерот.", "PERMISSIONCHECKV2": "Проверка на дозволи V2", "PERMISSIONCHECKV2_DESCRIPTION": "Ако знамето е овозможено, ќе можете да ја користите новата API и нејзините функции.", - "WEBKEY": "Веб клуч", - "WEBKEY_DESCRIPTION": "Ако знамето е овозможено, ќе можете да ја користите новата API и нејзините функции.", "STATES": { "INHERITED": "Наследи", "ENABLED": "Овозможено", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index c3de881784..c49867aa3e 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "De Back-Channel Logout implementeert OpenID Connect Back-Channel Logout 1.0 en kan worden gebruikt om clients te informeren over het beëindigen van de sessie bij de OpenID-provider.", "PERMISSIONCHECKV2": "Permissiecontrole V2", "PERMISSIONCHECKV2_DESCRIPTION": "Als de vlag is ingeschakeld, kunt u de nieuwe API en de bijbehorende functies gebruiken.", - "WEBKEY": "Websleutel", - "WEBKEY_DESCRIPTION": "Als de vlag is ingeschakeld, kunt u de nieuwe API en de bijbehorende functies gebruiken.", "STATES": { "INHERITED": "Overgenomen", "ENABLED": "Ingeschakeld", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index ca5476463d..abf2e1ba8a 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1641,8 +1641,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementuje OpenID Connect Back-Channel Logout 1.0 i może być używany do powiadamiania klientów o zakończeniu sesji u dostawcy OpenID.", "PERMISSIONCHECKV2": "Sprawdzanie uprawnień V2", "PERMISSIONCHECKV2_DESCRIPTION": "Jeśli flaga jest włączona, będziesz mógł korzystać z nowego API i jego funkcji.", - "WEBKEY": "Klucz Web", - "WEBKEY_DESCRIPTION": "Jeśli flaga jest włączona, będziesz mógł korzystać z nowego API i jego funkcji.", "STATES": { "INHERITED": "Dziedziczony", "ENABLED": "Włączony", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 7c68a4cada..8b858bd44e 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1643,8 +1643,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "O Logout de Back-Channel implementa o OpenID Connect Back-Channel Logout 1.0 e pode ser usado para notificar os clientes sobre a terminação da sessão no Provedor de OpenID.", "PERMISSIONCHECKV2": "Verificação de Permissão V2", "PERMISSIONCHECKV2_DESCRIPTION": "Se a bandeira estiver ativada, você poderá usar a nova API e seus recursos.", - "WEBKEY": "Chave Web", - "WEBKEY_DESCRIPTION": "Se a bandeira estiver ativada, você poderá usar a nova API e seus recursos.", "STATES": { "INHERITED": "Herdade", "ENABLED": "Habilitado", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index 0e0802a17c..d2f51a81e0 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1640,8 +1640,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Logout-ul Back-Channel implementează OpenID Connect Back-Channel Logout 1.0 și poate fi folosit pentru a notifica clienții despre terminarea sesiunii la Producătorul OpenID.", "PERMISSIONCHECKV2": "Verificare Permisiuni V2", "PERMISSIONCHECKV2_DESCRIPTION": "Dacă steagul este activat, veți putea folosi noua API și funcțiile sale.", - "WEBKEY": "Cheie Web", - "WEBKEY_DESCRIPTION": "Dacă steagul este activat, veți putea folosi noua API și funcțiile sale.", "STATES": { "INHERITED": "Moșteniți", "ENABLED": "Activat", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 8e06568a82..3070b311e7 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1695,8 +1695,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout реализует OpenID Connect Back-Channel Logout 1.0 и может использоваться для уведомления клиентов о завершении сеанса у поставщика OpenID.", "PERMISSIONCHECKV2": "Проверка Разрешений V2", "PERMISSIONCHECKV2_DESCRIPTION": "Если флаг включен, вы сможете использовать новый API и его функции.", - "WEBKEY": "Веб-ключ", - "WEBKEY_DESCRIPTION": "Если флаг включен, вы сможете использовать новый API и его функции.", "STATES": { "INHERITED": "Наследовать", "ENABLED": "Включено", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 1b80021a67..8f03501054 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1646,8 +1646,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel Logout implementerar OpenID Connect Back-Channel Logout 1.0 och kan användas för att meddela klienter om sessionens avslutning hos OpenID-leverantören.", "PERMISSIONCHECKV2": "Behörighetskontroll V2", "PERMISSIONCHECKV2_DESCRIPTION": "Om flaggan är aktiverad kan du använda den nya API:n och dess funktioner.", - "WEBKEY": "Webbnyckel", - "WEBKEY_DESCRIPTION": "Om flaggan är aktiverad kan du använda den nya API:n och dess funktioner.", "STATES": { "INHERITED": "Ärv", "ENABLED": "Aktiverad", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 9565b61eca..0431405979 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1642,8 +1642,6 @@ "ENABLEBACKCHANNELLOGOUT_DESCRIPTION": "Back-Channel 注销实现了 OpenID Connect Back-Channel Logout 1.0,可用于通知客户端在 OpenID 提供商处终止会话。", "PERMISSIONCHECKV2": "权限检查 V2", "PERMISSIONCHECKV2_DESCRIPTION": "如果启用该标志,您将能够使用新的 API 及其功能。", - "WEBKEY": "Web 密钥", - "WEBKEY_DESCRIPTION": "如果启用该标志,您将能够使用新的 API 及其功能。", "STATES": { "INHERITED": "继承", "ENABLED": "已启用", diff --git a/docs/docs/guides/integrate/login/oidc/webkeys.md b/docs/docs/guides/integrate/login/oidc/webkeys.md index 62f62a90e0..288284fefc 100644 --- a/docs/docs/guides/integrate/login/oidc/webkeys.md +++ b/docs/docs/guides/integrate/login/oidc/webkeys.md @@ -20,13 +20,6 @@ JWT access tokens, instead of [introspection](/docs/apis/openidoauth/endpoints#i ZITADEL uses public key verification when API calls are made or when the userInfo or introspection endpoints are called with a JWT access token. -:::info -Web keys are an [experimental](/docs/support/software-release-cycles-support#beta) feature. Be sure to enable the `web_key` [feature](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) before using it. - -The documentation describes the state of the feature in ZITADEL V3. -Test the feature and add improvement or bug reports directly to the [github repository](https://github.com/zitadel/zitadel) or let us know your general feedback in the [discord thread](https://discord.com/channels/927474939156643850/1329100936127320175/threads/1332344892629717075)! -::: - ### JSON Web Key ZITADEL implements the [RFC7517 - JSON Web Key (JWK)](https://www.rfc-editor.org/rfc/rfc7517) format for storage and distribution of public keys. diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 56d3009457..1f0a3b21e7 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -58,7 +58,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - WebKey: req.WebKey, DebugOIDCParentError: req.DebugOidcParentError, OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, DisableUserTokenEvent: req.DisableUserTokenEvent, @@ -77,7 +76,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - WebKey: featureSourceToFlagPb(&f.WebKey), DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index b77ed438f5..d09f1839ba 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -153,7 +153,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), DebugOidcParentError: gu.Ptr(true), OidcSingleV1SessionTermination: gu.Ptr(true), EnableBackChannelLogout: gu.Ptr(true), @@ -169,7 +168,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), DebugOIDCParentError: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), EnableBackChannelLogout: gu.Ptr(true), @@ -211,10 +209,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, - WebKey: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, OIDCSingleV1SessionTermination: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -265,10 +259,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, - WebKey: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, DebugOidcParentError: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 406146fdbe..8927b16e29 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -38,7 +38,6 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *comm UserSchema: req.UserSchema, TokenExchange: req.OidcTokenExchange, ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - WebKey: req.WebKey, DebugOIDCParentError: req.DebugOidcParentError, OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } @@ -52,7 +51,6 @@ func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeat UserSchema: featureSourceToFlagPb(&f.UserSchema), OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - WebKey: featureSourceToFlagPb(&f.WebKey), DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 2395574733..5fdb5e993e 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -111,7 +111,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), OidcTokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.InstanceFeatures{ @@ -120,7 +119,6 @@ func Test_instanceFeaturesToCommand(t *testing.T) { UserSchema: gu.Ptr(true), TokenExchange: gu.Ptr(true), ImprovedPerformance: nil, - WebKey: gu.Ptr(true), OIDCSingleV1SessionTermination: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) @@ -154,10 +152,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: []feature.ImprovedPerformanceType{feature.ImprovedPerformanceTypeOrgByID}, }, - WebKey: query.FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, OIDCSingleV1SessionTermination: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -189,10 +183,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { ExecutionPaths: []feature_pb.ImprovedPerformance{feature_pb.ImprovedPerformance_IMPROVED_PERFORMANCE_ORG_BY_ID}, Source: feature_pb.Source_SOURCE_SYSTEM, }, - WebKey: &feature_pb.FeatureFlag{ - Enabled: true, - Source: feature_pb.Source_SOURCE_INSTANCE, - }, DebugOidcParentError: &feature_pb.FeatureFlag{ Enabled: false, Source: feature_pb.Source_SOURCE_UNSPECIFIED, diff --git a/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go b/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go index 002669c233..0cbf629b43 100644 --- a/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go +++ b/internal/api/grpc/webkey/v2beta/integration_test/webkey_integration_test.go @@ -12,11 +12,9 @@ import ( "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) @@ -33,34 +31,8 @@ func TestMain(m *testing.M) { }()) } -func TestServer_Feature_Disabled(t *testing.T) { - instance, iamCtx, _ := createInstance(t, false) - client := instance.Client.WebKeyV2Beta - - t.Run("CreateWebKey", func(t *testing.T) { - _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{}) - assertFeatureDisabledError(t, err) - }) - t.Run("ActivateWebKey", func(t *testing.T) { - _, err := client.ActivateWebKey(iamCtx, &webkey.ActivateWebKeyRequest{ - Id: "1", - }) - assertFeatureDisabledError(t, err) - }) - t.Run("DeleteWebKey", func(t *testing.T) { - _, err := client.DeleteWebKey(iamCtx, &webkey.DeleteWebKeyRequest{ - Id: "1", - }) - assertFeatureDisabledError(t, err) - }) - t.Run("ListWebKeys", func(t *testing.T) { - _, err := client.ListWebKeys(iamCtx, &webkey.ListWebKeysRequest{}) - assertFeatureDisabledError(t, err) - }) -} - func TestServer_ListWebKeys(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) // After the feature is first enabled, we can expect 2 generated keys with the default config. checkWebKeyListState(iamCtx, t, instance, 2, "", &webkey.WebKey_Rsa{ Rsa: &webkey.RSA{ @@ -71,7 +43,7 @@ func TestServer_ListWebKeys(t *testing.T) { } func TestServer_CreateWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta _, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ @@ -93,7 +65,7 @@ func TestServer_CreateWebKey(t *testing.T) { } func TestServer_ActivateWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta resp, err := client.CreateWebKey(iamCtx, &webkey.CreateWebKeyRequest{ @@ -120,7 +92,7 @@ func TestServer_ActivateWebKey(t *testing.T) { } func TestServer_DeleteWebKey(t *testing.T) { - instance, iamCtx, creationDate := createInstance(t, true) + instance, iamCtx, creationDate := createInstance(t) client := instance.Client.WebKeyV2Beta keyIDs := make([]string, 2) @@ -197,40 +169,22 @@ func TestServer_DeleteWebKey(t *testing.T) { }, creationDate) } -func createInstance(t *testing.T, enableFeature bool) (*integration.Instance, context.Context, *timestamppb.Timestamp) { +func createInstance(t *testing.T) (*integration.Instance, context.Context, *timestamppb.Timestamp) { instance := integration.NewInstance(CTX) creationDate := timestamppb.Now() iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - if enableFeature { - _, err := instance.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(true), - }) - require.NoError(t, err) - } - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamCTX, time.Minute) assert.EventuallyWithT(t, func(collect *assert.CollectT) { resp, err := instance.Client.WebKeyV2Beta.ListWebKeys(iamCTX, &webkey.ListWebKeysRequest{}) - if enableFeature { - assert.NoError(collect, err) - assert.Len(collect, resp.GetWebKeys(), 2) - } else { - assert.Error(collect, err) - } + assert.NoError(collect, err) + assert.Len(collect, resp.GetWebKeys(), 2) + }, retryDuration, tick) return instance, iamCTX, creationDate } -func assertFeatureDisabledError(t *testing.T, err error) { - t.Helper() - require.Error(t, err) - s := status.Convert(err) - assert.Equal(t, codes.FailedPrecondition, s.Code()) - assert.Contains(t, s.Message(), "WEBKEY-Ohx6E") -} - func checkWebKeyListState(ctx context.Context, t *testing.T, instance *integration.Instance, nKeys int, expectActiveKeyID string, config any, creationDate *timestamppb.Timestamp) { t.Helper() diff --git a/internal/api/grpc/webkey/v2beta/webkey.go b/internal/api/grpc/webkey/v2beta/webkey.go index d45288dff2..469d6fc9a6 100644 --- a/internal/api/grpc/webkey/v2beta/webkey.go +++ b/internal/api/grpc/webkey/v2beta/webkey.go @@ -5,9 +5,7 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" webkey "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) @@ -15,9 +13,6 @@ func (s *Server) CreateWebKey(ctx context.Context, req *webkey.CreateWebKeyReque ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } webKey, err := s.command.CreateWebKey(ctx, createWebKeyRequestToConfig(req)) if err != nil { return nil, err @@ -33,9 +28,6 @@ func (s *Server) ActivateWebKey(ctx context.Context, req *webkey.ActivateWebKeyR ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } details, err := s.command.ActivateWebKey(ctx, req.GetId()) if err != nil { return nil, err @@ -50,9 +42,6 @@ func (s *Server) DeleteWebKey(ctx context.Context, req *webkey.DeleteWebKeyReque ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } deletedAt, err := s.command.DeleteWebKey(ctx, req.GetId()) if err != nil { return nil, err @@ -71,9 +60,6 @@ func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err = checkWebKeyFeature(ctx); err != nil { - return nil, err - } list, err := s.query.ListWebKeys(ctx) if err != nil { return nil, err @@ -83,10 +69,3 @@ func (s *Server) ListWebKeys(ctx context.Context, _ *webkey.ListWebKeysRequest) WebKeys: webKeyDetailsListToPb(list), }, nil } - -func checkWebKeyFeature(ctx context.Context) error { - if !authz.GetFeatures(ctx).WebKey { - return zerrors.ThrowPreconditionFailed(nil, "WEBKEY-Ohx6E", "Errors.WebKey.FeatureDisabled") - } - return nil -} diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 2f2880efc2..5c0b9c9f66 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -53,7 +53,7 @@ func (s *Server) verifyAccessToken(ctx context.Context, tkn string) (_ *accessTo tokenID, subject = split[0], split[1] } else { verifier := op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), s.accessTokenKeySet, - op.WithSupportedAccessTokenSigningAlgorithms(supportedSigningAlgs(ctx)...), + op.WithSupportedAccessTokenSigningAlgorithms(supportedSigningAlgs()...), ) claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, tkn, verifier) if err != nil { diff --git a/internal/api/oidc/auth_request_converter.go b/internal/api/oidc/auth_request_converter.go index 2144ca8ba1..064af20de0 100644 --- a/internal/api/oidc/auth_request_converter.go +++ b/internal/api/oidc/auth_request_converter.go @@ -140,13 +140,8 @@ func HttpHeadersFromContext(ctx context.Context) (userAgent, acceptLang string) if !ok { return } - if agents, ok := ctxHeaders[http_utils.UserAgentHeader]; ok { - userAgent = agents[0] - } - if langs, ok := ctxHeaders[http_utils.AcceptLanguage]; ok { - acceptLang = langs[0] - } - return userAgent, acceptLang + return ctxHeaders.Get(http_utils.UserAgentHeader), + ctxHeaders.Get(http_utils.AcceptLanguage) } func IpFromContext(ctx context.Context) net.IP { diff --git a/internal/api/oidc/integration_test/keys_test.go b/internal/api/oidc/integration_test/keys_test.go index 8b66e980d0..a6223cf1ee 100644 --- a/internal/api/oidc/integration_test/keys_test.go +++ b/internal/api/oidc/integration_test/keys_test.go @@ -14,12 +14,10 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/pkg/client" "github.com/zitadel/oidc/v3/pkg/oidc" - "google.golang.org/protobuf/proto" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) @@ -53,25 +51,16 @@ func TestServer_Keys(t *testing.T) { require.NoError(t, err) tests := []struct { - name string - webKeyFeature bool - wantLen int + name string + wantLen int }{ { - name: "legacy only", - webKeyFeature: false, - wantLen: 1, - }, - { - name: "webkeys with legacy", - webKeyFeature: true, - wantLen: 3, // 1 legacy + 2 created by enabling feature flag + name: "webkeys", + wantLen: 2, // 2 from instance creation. }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ensureWebKeyFeature(t, instance, tt.webKeyFeature) - assert.EventuallyWithT(t, func(ttt *assert.CollectT) { resp, err := http.Get(discovery.JwksURI) require.NoError(ttt, err) @@ -92,30 +81,10 @@ func TestServer_Keys(t *testing.T) { } cacheControl := resp.Header.Get("cache-control") - if tt.webKeyFeature { - require.Equal(ttt, "max-age=300, must-revalidate", cacheControl) - return - } - require.Equal(ttt, "no-store", cacheControl) + require.Equal(ttt, "max-age=300, must-revalidate", cacheControl) }, time.Minute, time.Second/10) }) } } - -func ensureWebKeyFeature(t *testing.T, instance *integration.Instance, set bool) { - ctxIam := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - - _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(set), - }) - require.NoError(t, err) - - t.Cleanup(func() { - _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{ - WebKey: proto.Bool(false), - }) - require.NoError(t, err) - }) -} diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index bf201b242e..b3bc836343 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -35,21 +35,14 @@ func TestServer_UserInfo(t *testing.T) { tests := []struct { name string trigger bool - webKey bool }{ { name: "trigger enabled", trigger: true, }, - - // This is the only functional test we need to cover web keys. - // - By creating tokens the signer is tested - // - When obtaining the tokens, the RP verifies the ID Token using the key set from the jwks endpoint. - // - By calling userinfo with the access token as JWT, the Token Verifier with the public key cache is tested. { - name: "web keys", + name: "trigger disabled", trigger: false, - webKey: true, }, } @@ -57,7 +50,6 @@ func TestServer_UserInfo(t *testing.T) { t.Run(tt.name, func(t *testing.T) { _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ OidcTriggerIntrospectionProjections: &tt.trigger, - WebKey: &tt.webKey, }) require.NoError(t, err) testServer_UserInfo(t) diff --git a/internal/api/oidc/key.go b/internal/api/oidc/key.go index 852bbc7db8..61f874664f 100644 --- a/internal/api/oidc/key.go +++ b/internal/api/oidc/key.go @@ -10,18 +10,12 @@ import ( "github.com/go-jose/go-jose/v4" "github.com/jonboulle/clockwork" - "github.com/muhlemmer/gu" - "github.com/shopspring/decimal" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/api/authz" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/repository/instance" - "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -36,11 +30,8 @@ var supportedWebKeyAlgs = []string{ string(jose.ES512), } -func supportedSigningAlgs(ctx context.Context) []string { - if authz.GetFeatures(ctx).WebKey { - return supportedWebKeyAlgs - } - return []string{string(jose.RS256)} +func supportedSigningAlgs() []string { + return supportedWebKeyAlgs } type cachedPublicKey struct { @@ -211,15 +202,6 @@ func withKeyExpiryCheck(check bool) keySetOption { } } -func jsonWebkey(key query.PublicKey) *jose.JSONWebKey { - return &jose.JSONWebKey{ - KeyID: key.ID(), - Algorithm: key.Algorithm(), - Use: key.Use().String(), - Key: key.Key(), - } -} - // keySetMap is a mapping of key IDs to public key data. type keySetMap map[string][]byte @@ -250,7 +232,6 @@ func (k keySetMap) VerifySignature(ctx context.Context, jws *jose.JSONWebSignatu } const ( - locksTable = "projections.locks" signingKey = "signing_key" oidcUser = "OIDC" @@ -279,203 +260,36 @@ func (s *SigningKey) ID() string { return s.id } -// PublicKey wraps the query.PublicKey to implement the op.Key interface -type PublicKey struct { - key query.PublicKey -} - -func (s *PublicKey) Algorithm() jose.SignatureAlgorithm { - return jose.SignatureAlgorithm(s.key.Algorithm()) -} - -func (s *PublicKey) Use() string { - return s.key.Use().String() -} - -func (s *PublicKey) Key() interface{} { - return s.key.Key() -} - -func (s *PublicKey) ID() string { - return s.key.ID() -} - // KeySet implements the op.Storage interface func (o *OPStorage) KeySet(ctx context.Context) (keys []op.Key, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - err = retry(func() error { - publicKeys, err := o.query.ActivePublicKeys(ctx, time.Now()) - if err != nil { - return err - } - keys = make([]op.Key, len(publicKeys.Keys)) - for i, key := range publicKeys.Keys { - keys[i] = &PublicKey{key} - } - return nil - }) - return keys, err + panic(o.panicErr("KeySet")) } // SignatureAlgorithms implements the op.Storage interface func (o *OPStorage) SignatureAlgorithms(ctx context.Context) ([]jose.SignatureAlgorithm, error) { - key, err := o.SigningKey(ctx) - if err != nil { - logging.WithError(err).Warn("unable to fetch signing key") - return nil, err - } - return []jose.SignatureAlgorithm{key.SignatureAlgorithm()}, nil + panic(o.panicErr("SignatureAlgorithms")) } // SigningKey implements the op.Storage interface func (o *OPStorage) SigningKey(ctx context.Context) (key op.SigningKey, err error) { - err = retry(func() error { - key, err = o.getSigningKey(ctx) - if err != nil { - return err - } - if key == nil { - return zerrors.ThrowNotFound(nil, "OIDC-ve4Qu", "Errors.Internal") - } - return nil - }) - return key, err -} - -func (o *OPStorage) getSigningKey(ctx context.Context) (op.SigningKey, error) { - keys, err := o.query.ActivePrivateSigningKey(ctx, time.Now().Add(gracefulPeriod)) - if err != nil { - return nil, err - } - if len(keys.Keys) > 0 { - return PrivateKeyToSigningKey(SelectSigningKey(keys.Keys), o.encAlg) - } - var position decimal.Decimal - if keys.State != nil { - position = keys.State.Position - } - return nil, o.refreshSigningKey(ctx, position) -} - -func (o *OPStorage) refreshSigningKey(ctx context.Context, position decimal.Decimal) error { - ok, err := o.ensureIsLatestKey(ctx, position) - if err != nil || !ok { - return zerrors.ThrowInternal(err, "OIDC-ASfh3", "cannot ensure that projection is up to date") - } - err = o.lockAndGenerateSigningKeyPair(ctx) - if err != nil { - return zerrors.ThrowInternal(err, "OIDC-ADh31", "could not create signing key") - } - return zerrors.ThrowInternal(nil, "OIDC-Df1bh", "") -} - -func (o *OPStorage) ensureIsLatestKey(ctx context.Context, position decimal.Decimal) (bool, error) { - maxSequence, err := o.getMaxKeyPosition(ctx) - if err != nil { - return false, fmt.Errorf("error retrieving new events: %w", err) - } - return position.GreaterThanOrEqual(maxSequence), nil -} - -func PrivateKeyToSigningKey(key query.PrivateKey, algorithm crypto.EncryptionAlgorithm) (_ op.SigningKey, err error) { - keyData, err := crypto.Decrypt(key.Key(), algorithm) - if err != nil { - return nil, err - } - privateKey, err := crypto.BytesToPrivateKey(keyData) - if err != nil { - return nil, err - } - return &SigningKey{ - algorithm: jose.SignatureAlgorithm(key.Algorithm()), - key: privateKey, - id: key.ID(), - }, nil -} - -func (o *OPStorage) lockAndGenerateSigningKeyPair(ctx context.Context) error { - logging.Info("lock and generate signing key pair") - - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - errs := o.locker.Lock(ctx, lockDuration, authz.GetInstance(ctx).InstanceID()) - err, ok := <-errs - if err != nil || !ok { - if zerrors.IsErrorAlreadyExists(err) { - return nil - } - logging.OnError(err).Debug("initial lock failed") - return err - } - - return o.command.GenerateSigningKeyPair(setOIDCCtx(ctx), "RS256") -} - -func (o *OPStorage) getMaxKeyPosition(ctx context.Context) (decimal.Decimal, error) { - return o.eventstore.LatestPosition(ctx, - eventstore.NewSearchQueryBuilder(eventstore.ColumnsMaxPosition). - ResourceOwner(authz.GetInstance(ctx).InstanceID()). - AwaitOpenTransactions(). - AddQuery(). - AggregateTypes( - keypair.AggregateType, - instance.AggregateType, - ). - EventTypes( - keypair.AddedEventType, - instance.InstanceRemovedEventType, - ). - Builder(), - ) -} - -func SelectSigningKey(keys []query.PrivateKey) query.PrivateKey { - return keys[len(keys)-1] -} - -func setOIDCCtx(ctx context.Context) context.Context { - return authz.SetCtxData(ctx, authz.CtxData{UserID: oidcUser, OrgID: authz.GetInstance(ctx).InstanceID()}) -} - -func retry(retryable func() error) (err error) { - for i := 0; i < retryCount; i++ { - err = retryable() - if err == nil { - return nil - } - time.Sleep(retryBackoff) - } - return err + panic(o.panicErr("SigningKey")) } func (s *Server) Keys(ctx context.Context, r *op.Request[struct{}]) (_ *op.Response, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if !authz.GetFeatures(ctx).WebKey { - return s.LegacyServer.Keys(ctx, r) - } - keyset, err := s.query.GetWebKeySet(ctx) if err != nil { return nil, err } - // Return legacy keys, so we do not invalidate all tokens - // once the feature flag is enabled. - legacyKeys, err := s.query.ActivePublicKeys(ctx, time.Now()) - logging.OnError(err).Error("oidc server: active public keys (legacy)") - appendPublicKeysToWebKeySet(keyset, legacyKeys) - resp := op.NewResponse(keyset) if s.jwksCacheControlMaxAge != 0 { resp.Header.Set(http_util.CacheControl, fmt.Sprintf("max-age=%d, must-revalidate", int(s.jwksCacheControlMaxAge/time.Second)), ) } - return resp, nil } @@ -497,20 +311,10 @@ func appendPublicKeysToWebKeySet(keyset *jose.JSONWebKeySet, pubkeys *query.Publ func queryKeyFunc(q *query.Queries) func(ctx context.Context, keyID string) (*jose.JSONWebKey, *time.Time, error) { return func(ctx context.Context, keyID string) (*jose.JSONWebKey, *time.Time, error) { - if authz.GetFeatures(ctx).WebKey { - webKey, err := q.GetPublicWebKeyByID(ctx, keyID) - if err == nil { - return webKey, nil, nil - } - if !zerrors.IsNotFound(err) { - return nil, nil, err - } - } - - pubKey, err := q.GetPublicKeyByID(ctx, keyID) + webKey, err := q.GetPublicWebKeyByID(ctx, keyID) if err != nil { return nil, nil, err } - return jsonWebkey(pubKey), gu.Ptr(pubKey.Expiry()), nil + return webKey, nil, nil } } diff --git a/internal/api/oidc/op.go b/internal/api/oidc/op.go index d7171b957b..6f59ce3525 100644 --- a/internal/api/oidc/op.go +++ b/internal/api/oidc/op.go @@ -18,10 +18,8 @@ import ( "github.com/zitadel/zitadel/internal/cache" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain/federatedlogout" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/handler/crdb" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/telemetry/metrics" "github.com/zitadel/zitadel/internal/zerrors" @@ -75,7 +73,6 @@ type OPStorage struct { defaultRefreshTokenIdleExpiration time.Duration defaultRefreshTokenExpiration time.Duration encAlg crypto.EncryptionAlgorithm - locker crdb.Locker assetAPIPrefix func(ctx context.Context) string contextToIssuer func(context.Context) string federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout] @@ -91,14 +88,14 @@ type Provider struct { // IDTokenHintVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context. func (o *Provider) IDTokenHintVerifier(ctx context.Context) *op.IDTokenHintVerifier { return op.NewIDTokenHintVerifier(op.IssuerFromContext(ctx), o.idTokenHintKeySet, op.WithSupportedIDTokenHintSigningAlgorithms( - supportedSigningAlgs(ctx)..., + supportedSigningAlgs()..., )) } // AccessTokenVerifier configures a Verifier and supported signing algorithms based on the Web Key feature in the context. func (o *Provider) AccessTokenVerifier(ctx context.Context) *op.AccessTokenVerifier { return op.NewAccessTokenVerifier(op.IssuerFromContext(ctx), o.accessTokenKeySet, op.WithSupportedAccessTokenSigningAlgorithms( - supportedSigningAlgs(ctx)..., + supportedSigningAlgs()..., )) } @@ -113,7 +110,6 @@ func NewServer( encryptionAlg crypto.EncryptionAlgorithm, cryptoKey []byte, es *eventstore.Eventstore, - projections *database.DB, userAgentCookie, instanceHandler func(http.Handler) http.Handler, accessHandler *middleware.AccessInterceptor, fallbackLogger *slog.Logger, @@ -124,7 +120,7 @@ func NewServer( if err != nil { return nil, zerrors.ThrowInternal(err, "OIDC-EGrqd", "cannot create op config: %w") } - storage := newStorage(config, command, query, repo, encryptionAlg, es, projections, ContextToIssuer, federatedLogoutCache) + storage := newStorage(config, command, query, repo, encryptionAlg, es, ContextToIssuer, federatedLogoutCache) keyCache := newPublicKeyCache(ctx, config.PublicKeyCacheMaxAge, queryKeyFunc(query)) accessTokenKeySet := newOidcKeySet(keyCache, withKeyExpiryCheck(true)) idTokenHintKeySet := newOidcKeySet(keyCache) @@ -236,7 +232,6 @@ func newStorage( repo repository.Repository, encAlg crypto.EncryptionAlgorithm, es *eventstore.Eventstore, - db *database.DB, contextToIssuer func(context.Context) string, federateLogoutCache cache.Cache[federatedlogout.Index, string, *federatedlogout.FederatedLogout], ) *OPStorage { @@ -253,7 +248,6 @@ func newStorage( defaultRefreshTokenIdleExpiration: config.DefaultRefreshTokenIdleExpiration, defaultRefreshTokenExpiration: config.DefaultRefreshTokenExpiration, encAlg: encAlg, - locker: crdb.NewLocker(db.DB, locksTable, signingKey), assetAPIPrefix: assets.AssetAPI(), contextToIssuer: contextToIssuer, federateLogoutCache: federateLogoutCache, diff --git a/internal/api/oidc/server.go b/internal/api/oidc/server.go index 1a0854e2a6..df7127443f 100644 --- a/internal/api/oidc/server.go +++ b/internal/api/oidc/server.go @@ -188,7 +188,7 @@ func (s *Server) createDiscoveryConfig(ctx context.Context, supportedUILocales o }, GrantTypesSupported: op.GrantTypes(s.Provider()), SubjectTypesSupported: op.SubjectTypes(s.Provider()), - IDTokenSigningAlgValuesSupported: supportedSigningAlgs(ctx), + IDTokenSigningAlgValuesSupported: supportedSigningAlgs(), RequestObjectSigningAlgValuesSupported: op.RequestObjectSigAlgorithms(s.Provider()), TokenEndpointAuthMethodsSupported: op.AuthMethodsTokenEndpoint(s.Provider()), TokenEndpointAuthSigningAlgValuesSupported: op.TokenSigAlgorithms(s.Provider()), diff --git a/internal/api/oidc/server_test.go b/internal/api/oidc/server_test.go index 76d073151a..9bf22fd210 100644 --- a/internal/api/oidc/server_test.go +++ b/internal/api/oidc/server_test.go @@ -8,9 +8,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" "golang.org/x/text/language" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/feature" ) func TestServer_createDiscoveryConfig(t *testing.T) { @@ -63,92 +60,6 @@ func TestServer_createDiscoveryConfig(t *testing.T) { ctx: op.ContextWithIssuer(context.Background(), "https://issuer.com"), supportedUILocales: []language.Tag{language.English, language.German}, }, - &oidc.DiscoveryConfiguration{ - Issuer: "https://issuer.com", - AuthorizationEndpoint: "https://issuer.com/auth", - TokenEndpoint: "https://issuer.com/token", - IntrospectionEndpoint: "https://issuer.com/introspect", - UserinfoEndpoint: "https://issuer.com/userinfo", - RevocationEndpoint: "https://issuer.com/revoke", - EndSessionEndpoint: "https://issuer.com/logout", - DeviceAuthorizationEndpoint: "https://issuer.com/device", - CheckSessionIframe: "", - JwksURI: "https://issuer.com/keys", - RegistrationEndpoint: "", - ScopesSupported: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopePhone, oidc.ScopeAddress, oidc.ScopeOfflineAccess}, - ResponseTypesSupported: []string{string(oidc.ResponseTypeCode), string(oidc.ResponseTypeIDTokenOnly), string(oidc.ResponseTypeIDToken)}, - ResponseModesSupported: []string{string(oidc.ResponseModeQuery), string(oidc.ResponseModeFragment), string(oidc.ResponseModeFormPost)}, - GrantTypesSupported: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeImplicit, oidc.GrantTypeRefreshToken, oidc.GrantTypeBearer}, - ACRValuesSupported: nil, - SubjectTypesSupported: []string{"public"}, - IDTokenSigningAlgValuesSupported: []string{"RS256"}, - IDTokenEncryptionAlgValuesSupported: nil, - IDTokenEncryptionEncValuesSupported: nil, - UserinfoSigningAlgValuesSupported: nil, - UserinfoEncryptionAlgValuesSupported: nil, - UserinfoEncryptionEncValuesSupported: nil, - RequestObjectSigningAlgValuesSupported: []string{"RS256"}, - RequestObjectEncryptionAlgValuesSupported: nil, - RequestObjectEncryptionEncValuesSupported: nil, - TokenEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost, oidc.AuthMethodPrivateKeyJWT}, - TokenEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - RevocationEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodNone, oidc.AuthMethodBasic, oidc.AuthMethodPost, oidc.AuthMethodPrivateKeyJWT}, - RevocationEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - IntrospectionEndpointAuthMethodsSupported: []oidc.AuthMethod{oidc.AuthMethodBasic, oidc.AuthMethodPrivateKeyJWT}, - IntrospectionEndpointAuthSigningAlgValuesSupported: []string{"RS256"}, - DisplayValuesSupported: nil, - ClaimTypesSupported: nil, - ClaimsSupported: []string{"sub", "aud", "exp", "iat", "iss", "auth_time", "nonce", "acr", "amr", "c_hash", "at_hash", "act", "scopes", "client_id", "azp", "preferred_username", "name", "family_name", "given_name", "locale", "email", "email_verified", "phone_number", "phone_number_verified"}, - ClaimsParameterSupported: false, - CodeChallengeMethodsSupported: []oidc.CodeChallengeMethod{"S256"}, - ServiceDocumentation: "", - ClaimsLocalesSupported: nil, - UILocalesSupported: []language.Tag{language.English, language.German}, - RequestParameterSupported: true, - RequestURIParameterSupported: false, - RequireRequestURIRegistration: false, - OPPolicyURI: "", - OPTermsOfServiceURI: "", - }, - }, - { - "web keys feature enabled", - fields{ - LegacyServer: op.NewLegacyServer( - func() *op.Provider { - //nolint:staticcheck - provider, _ := op.NewForwardedOpenIDProvider("path", - &op.Config{ - CodeMethodS256: true, - AuthMethodPost: true, - AuthMethodPrivateKeyJWT: true, - GrantTypeRefreshToken: true, - RequestObjectSupported: true, - }, - nil, - ) - return provider - }(), - op.Endpoints{ - Authorization: op.NewEndpoint("auth"), - Token: op.NewEndpoint("token"), - Introspection: op.NewEndpoint("introspect"), - Userinfo: op.NewEndpoint("userinfo"), - Revocation: op.NewEndpoint("revoke"), - EndSession: op.NewEndpoint("logout"), - JwksURI: op.NewEndpoint("keys"), - DeviceAuthorization: op.NewEndpoint("device"), - }, - ), - signingKeyAlgorithm: "RS256", - }, - args{ - ctx: authz.WithFeatures( - op.ContextWithIssuer(context.Background(), "https://issuer.com"), - feature.Features{WebKey: true}, - ), - supportedUILocales: []language.Tag{language.English, language.German}, - }, &oidc.DiscoveryConfiguration{ Issuer: "https://issuer.com", AuthorizationEndpoint: "https://issuer.com/auth", diff --git a/internal/api/oidc/token.go b/internal/api/oidc/token.go index 485f455784..2efc0fb583 100644 --- a/internal/api/oidc/token.go +++ b/internal/api/oidc/token.go @@ -12,7 +12,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/oidc" "github.com/zitadel/oidc/v3/pkg/op" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -64,14 +63,13 @@ func (s *Server) accessTokenResponseFromSession(ctx context.Context, client op.C type SignerFunc func(ctx context.Context) (jose.Signer, jose.SignatureAlgorithm, error) func (s *Server) getSignerOnce() SignerFunc { - return GetSignerOnce(s.query.GetActiveSigningWebKey, s.Provider().Storage().SigningKey) + return GetSignerOnce(s.query.GetActiveSigningWebKey) } // GetSignerOnce returns a function which retrieves the instance's signer from the database once. // Repeated calls of the returned function return the same results. func GetSignerOnce( getActiveSigningWebKey func(ctx context.Context) (*jose.JSONWebKey, error), - getSigningKey func(ctx context.Context) (op.SigningKey, error), ) SignerFunc { var ( once sync.Once @@ -84,23 +82,12 @@ func GetSignerOnce( ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if authz.GetFeatures(ctx).WebKey { - var webKey *jose.JSONWebKey - webKey, err = getActiveSigningWebKey(ctx) - if err != nil { - return - } - signer, signAlg, err = signerFromWebKey(webKey) - return - } - - var signingKey op.SigningKey - signingKey, err = getSigningKey(ctx) + var webKey *jose.JSONWebKey + webKey, err = getActiveSigningWebKey(ctx) if err != nil { return } - signAlg = signingKey.SignatureAlgorithm() - signer, err = op.SignerFromKey(signingKey) + signer, signAlg, err = signerFromWebKey(webKey) }) return signer, signAlg, err } diff --git a/internal/api/saml/certificate.go b/internal/api/saml/certificate.go index 14752cd5cd..e0eb31255e 100644 --- a/internal/api/saml/certificate.go +++ b/internal/api/saml/certificate.go @@ -14,13 +14,14 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/keypair" "github.com/zitadel/zitadel/internal/zerrors" ) const ( - locksTable = "projections.locks" + locksTable = projection.LocksTable signingKey = "signing_key" samlUser = "SAML" diff --git a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go index b707631c22..d6c14afea3 100644 --- a/internal/authz/repository/eventsourcing/eventstore/token_verifier.go +++ b/internal/authz/repository/eventsourcing/eventstore/token_verifier.go @@ -4,7 +4,6 @@ import ( "context" "encoding/base64" "fmt" - "slices" "strings" "time" @@ -329,18 +328,10 @@ type openIDKeySet struct { // VerifySignature implements the oidc.KeySet interface // providing an implementation for the keys retrieved directly from Queries func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) { - keySet := new(jose.JSONWebKeySet) - if authz.GetFeatures(ctx).WebKey { - keySet, err = o.Queries.GetWebKeySet(ctx) - if err != nil { - return nil, err - } - } - legacyKeySet, err := o.Queries.ActivePublicKeys(ctx, time.Now()) + keySet, err := o.Queries.GetWebKeySet(ctx) if err != nil { - return nil, fmt.Errorf("error fetching keys: %w", err) + return nil, err } - appendPublicKeysToWebKeySet(keySet, legacyKeySet) keyID, alg := oidc.GetKeyIDAndAlg(jws) key, err := oidc.FindMatchingKey(keyID, oidc.KeyUseSignature, alg, keySet.Keys...) if err != nil { @@ -348,19 +339,3 @@ func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSig } return jws.Verify(&key) } - -func appendPublicKeysToWebKeySet(keyset *jose.JSONWebKeySet, pubkeys *query.PublicKeys) { - if pubkeys == nil || len(pubkeys.Keys) == 0 { - return - } - keyset.Keys = slices.Grow(keyset.Keys, len(pubkeys.Keys)) - - for _, key := range pubkeys.Keys { - keyset.Keys = append(keyset.Keys, jose.JSONWebKey{ - Key: key.Key(), - KeyID: key.ID(), - Algorithm: key.Algorithm(), - Use: key.Use().String(), - }) - } -} diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 4d35d5a318..21de5653a9 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -3,11 +3,8 @@ package command import ( "context" - "github.com/muhlemmer/gu" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" - "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/feature" @@ -21,7 +18,6 @@ type InstanceFeatures struct { UserSchema *bool TokenExchange *bool ImprovedPerformance []feature.ImprovedPerformanceType - WebKey *bool DebugOIDCParentError *bool OIDCSingleV1SessionTermination *bool DisableUserTokenEvent *bool @@ -38,7 +34,6 @@ func (m *InstanceFeatures) isEmpty() bool { m.TokenExchange == nil && // nil check to allow unset improvements m.ImprovedPerformance == nil && - m.WebKey == nil && m.DebugOIDCParentError == nil && m.OIDCSingleV1SessionTermination == nil && m.DisableUserTokenEvent == nil && @@ -55,9 +50,6 @@ func (c *Commands) SetInstanceFeatures(ctx context.Context, f *InstanceFeatures) if err := c.eventstore.FilterToQueryReducer(ctx, wm); err != nil { return nil, err } - if err := c.setupWebKeyFeature(ctx, wm, f); err != nil { - return nil, err - } commands := wm.setCommands(ctx, f) if len(commands) == 0 { return writeModelToObjectDetails(wm.WriteModel), nil @@ -78,21 +70,6 @@ func prepareSetFeatures(instanceID string, f *InstanceFeatures) preparation.Vali } } -// setupWebKeyFeature generates the initial web keys for the instance, -// if the feature is enabled in the request and the feature wasn't enabled already in the writeModel. -// [Commands.GenerateInitialWebKeys] checks if keys already exist and does nothing if that's the case. -// The default config of a RSA key with 2048 and the SHA256 hasher is assumed. -// Users can customize this after using the webkey/v3 API. -func (c *Commands) setupWebKeyFeature(ctx context.Context, wm *InstanceFeaturesWriteModel, f *InstanceFeatures) error { - if !gu.Value(f.WebKey) || gu.Value(wm.WebKey) { - return nil - } - return c.GenerateInitialWebKeys(ctx, &crypto.WebKeyRSAConfig{ - Bits: crypto.RSABits2048, - Hasher: crypto.RSAHasherSHA256, - }) -} - func (c *Commands) ResetInstanceFeatures(ctx context.Context) (*domain.ObjectDetails, error) { instanceID := authz.GetInstance(ctx).InstanceID() wm := NewInstanceFeaturesWriteModel(instanceID) diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 399013aded..8ca2865eae 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -71,7 +71,6 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, - feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, @@ -106,9 +105,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyImprovedPerformance: v := value.([]feature.ImprovedPerformanceType) features.ImprovedPerformance = v - case feature.KeyWebKey: - v := value.(bool) - features.WebKey = &v case feature.KeyDebugOIDCParentError: v := value.(bool) features.DebugOIDCParentError = &v @@ -140,7 +136,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.WebKey, f.WebKey, feature_v2.InstanceWebKeyEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DebugOIDCParentError, f.DebugOIDCParentError, feature_v2.InstanceDebugOIDCParentErrorEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.OIDCSingleV1SessionTermination, f.OIDCSingleV1SessionTermination, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.DisableUserTokenEvent, f.DisableUserTokenEvent, feature_v2.InstanceDisableUserTokenEvent) diff --git a/internal/command/key_pair.go b/internal/command/key_pair.go index 90eaf7e3da..76193431d6 100644 --- a/internal/command/key_pair.go +++ b/internal/command/key_pair.go @@ -13,31 +13,6 @@ import ( "github.com/zitadel/zitadel/internal/repository/keypair" ) -func (c *Commands) GenerateSigningKeyPair(ctx context.Context, algorithm string) error { - privateCrypto, publicCrypto, err := crypto.GenerateEncryptedKeyPair(c.keySize, c.keyAlgorithm) - if err != nil { - return err - } - keyID, err := c.idGenerator.Next() - if err != nil { - return err - } - - privateKeyExp := time.Now().UTC().Add(c.privateKeyLifetime) - publicKeyExp := time.Now().UTC().Add(c.publicKeyLifetime) - - keyPairWriteModel := NewKeyPairWriteModel(keyID, authz.GetInstance(ctx).InstanceID()) - keyAgg := KeyPairAggregateFromWriteModel(&keyPairWriteModel.WriteModel) - _, err = c.eventstore.Push(ctx, keypair.NewAddedEvent( - ctx, - keyAgg, - crypto.KeyUsageSigning, - algorithm, - privateCrypto, publicCrypto, - privateKeyExp, publicKeyExp)) - return err -} - func (c *Commands) GenerateSAMLCACertificate(ctx context.Context, algorithm string) error { now := time.Now().UTC() after := now.Add(c.certificateLifetime) diff --git a/internal/crypto/rsa.go b/internal/crypto/rsa.go index 198610d8aa..3fd9a77569 100644 --- a/internal/crypto/rsa.go +++ b/internal/crypto/rsa.go @@ -21,14 +21,6 @@ func GenerateKeyPair(bits int) (*rsa.PrivateKey, *rsa.PublicKey, error) { return privkey, &privkey.PublicKey, nil } -func GenerateEncryptedKeyPair(bits int, alg EncryptionAlgorithm) (*CryptoValue, *CryptoValue, error) { - privateKey, publicKey, err := GenerateKeyPair(bits) - if err != nil { - return nil, nil, err - } - return EncryptKeys(privateKey, publicKey, alg) -} - type CertificateInformations struct { SerialNumber *big.Int Organisation []string diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 2e32b6b122..107b06edf1 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,7 +9,7 @@ import ( type Key int const ( - // Reserved: 3, 6 + // Reserved: 3, 6, 8 KeyUnspecified Key = 0 KeyLoginDefaultOrg Key = 1 @@ -17,7 +17,6 @@ const ( KeyUserSchema Key = 4 KeyTokenExchange Key = 5 KeyImprovedPerformance Key = 7 - KeyWebKey Key = 8 KeyDebugOIDCParentError Key = 9 KeyOIDCSingleV1SessionTermination Key = 10 KeyDisableUserTokenEvent Key = 11 @@ -46,7 +45,6 @@ type Features struct { UserSchema bool `json:"user_schema,omitempty"` TokenExchange bool `json:"token_exchange,omitempty"` ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` - WebKey bool `json:"web_key,omitempty"` DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index e06199120a..1b4fb9a3ad 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -12,14 +12,17 @@ const ( _KeyLowerName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" _KeyName_1 = "user_schematoken_exchange" _KeyLowerName_1 = "user_schematoken_exchange" - _KeyName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" - _KeyLowerName_2 = "improved_performanceweb_keydebug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" + _KeyName_2 = "improved_performance" + _KeyLowerName_2 = "improved_performance" + _KeyName_3 = "debug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" + _KeyLowerName_3 = "debug_oidc_parent_erroroidc_single_v1_session_terminationdisable_user_token_eventenable_back_channel_logoutlogin_v2permission_check_v2console_use_v2_user_api" ) var ( _KeyIndex_0 = [...]uint8{0, 11, 28, 61} _KeyIndex_1 = [...]uint8{0, 11, 25} - _KeyIndex_2 = [...]uint8{0, 20, 27, 50, 84, 108, 134, 142, 161, 184} + _KeyIndex_2 = [...]uint8{0, 20} + _KeyIndex_3 = [...]uint8{0, 23, 57, 81, 107, 115, 134, 157} ) func (i Key) String() string { @@ -29,9 +32,11 @@ func (i Key) String() string { case 4 <= i && i <= 5: i -= 4 return _KeyName_1[_KeyIndex_1[i]:_KeyIndex_1[i+1]] - case 7 <= i && i <= 15: - i -= 7 - return _KeyName_2[_KeyIndex_2[i]:_KeyIndex_2[i+1]] + case i == 7: + return _KeyName_2 + case 9 <= i && i <= 15: + i -= 9 + return _KeyName_3[_KeyIndex_3[i]:_KeyIndex_3[i+1]] default: return fmt.Sprintf("Key(%d)", i) } @@ -47,7 +52,6 @@ func _KeyNoOp() { _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] _ = x[KeyImprovedPerformance-(7)] - _ = x[KeyWebKey-(8)] _ = x[KeyDebugOIDCParentError-(9)] _ = x[KeyOIDCSingleV1SessionTermination-(10)] _ = x[KeyDisableUserTokenEvent-(11)] @@ -57,7 +61,7 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyWebKey, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ _KeyName_0[0:11]: KeyUnspecified, @@ -72,22 +76,20 @@ var _KeyNameToValueMap = map[string]Key{ _KeyLowerName_1[11:25]: KeyTokenExchange, _KeyName_2[0:20]: KeyImprovedPerformance, _KeyLowerName_2[0:20]: KeyImprovedPerformance, - _KeyName_2[20:27]: KeyWebKey, - _KeyLowerName_2[20:27]: KeyWebKey, - _KeyName_2[27:50]: KeyDebugOIDCParentError, - _KeyLowerName_2[27:50]: KeyDebugOIDCParentError, - _KeyName_2[50:84]: KeyOIDCSingleV1SessionTermination, - _KeyLowerName_2[50:84]: KeyOIDCSingleV1SessionTermination, - _KeyName_2[84:108]: KeyDisableUserTokenEvent, - _KeyLowerName_2[84:108]: KeyDisableUserTokenEvent, - _KeyName_2[108:134]: KeyEnableBackChannelLogout, - _KeyLowerName_2[108:134]: KeyEnableBackChannelLogout, - _KeyName_2[134:142]: KeyLoginV2, - _KeyLowerName_2[134:142]: KeyLoginV2, - _KeyName_2[142:161]: KeyPermissionCheckV2, - _KeyLowerName_2[142:161]: KeyPermissionCheckV2, - _KeyName_2[161:184]: KeyConsoleUseV2UserApi, - _KeyLowerName_2[161:184]: KeyConsoleUseV2UserApi, + _KeyName_3[0:23]: KeyDebugOIDCParentError, + _KeyLowerName_3[0:23]: KeyDebugOIDCParentError, + _KeyName_3[23:57]: KeyOIDCSingleV1SessionTermination, + _KeyLowerName_3[23:57]: KeyOIDCSingleV1SessionTermination, + _KeyName_3[57:81]: KeyDisableUserTokenEvent, + _KeyLowerName_3[57:81]: KeyDisableUserTokenEvent, + _KeyName_3[81:107]: KeyEnableBackChannelLogout, + _KeyLowerName_3[81:107]: KeyEnableBackChannelLogout, + _KeyName_3[107:115]: KeyLoginV2, + _KeyLowerName_3[107:115]: KeyLoginV2, + _KeyName_3[115:134]: KeyPermissionCheckV2, + _KeyLowerName_3[115:134]: KeyPermissionCheckV2, + _KeyName_3[134:157]: KeyConsoleUseV2UserApi, + _KeyLowerName_3[134:157]: KeyConsoleUseV2UserApi, } var _KeyNames = []string{ @@ -97,14 +99,13 @@ var _KeyNames = []string{ _KeyName_1[0:11], _KeyName_1[11:25], _KeyName_2[0:20], - _KeyName_2[20:27], - _KeyName_2[27:50], - _KeyName_2[50:84], - _KeyName_2[84:108], - _KeyName_2[108:134], - _KeyName_2[134:142], - _KeyName_2[142:161], - _KeyName_2[161:184], + _KeyName_3[0:23], + _KeyName_3[23:57], + _KeyName_3[57:81], + _KeyName_3[81:107], + _KeyName_3[107:115], + _KeyName_3[115:134], + _KeyName_3[134:157], } // KeyString retrieves an enum value from the enum constants string name. diff --git a/internal/notification/handlers/back_channel_logout.go b/internal/notification/handlers/back_channel_logout.go index f1a99146ca..983915ac28 100644 --- a/internal/notification/handlers/back_channel_logout.go +++ b/internal/notification/handlers/back_channel_logout.go @@ -7,10 +7,8 @@ import ( "sync" "time" - "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/crypto" "github.com/zitadel/oidc/v3/pkg/oidc" - "github.com/zitadel/oidc/v3/pkg/op" "github.com/zitadel/zitadel/internal/api/authz" http_utils "github.com/zitadel/zitadel/internal/api/http" @@ -149,7 +147,7 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return err } - getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey, u.signingKey) + getSigner := zoidc.GetSignerOnce(u.queries.GetActiveSigningWebKey) var wg sync.WaitGroup wg.Add(len(sessions.sessions)) @@ -172,20 +170,6 @@ func (u *backChannelLogoutNotifier) terminateSession(ctx context.Context, id str return errors.Join(errs...) } -func (u *backChannelLogoutNotifier) signingKey(ctx context.Context) (op.SigningKey, error) { - keys, err := u.queries.ActivePrivateSigningKey(ctx, time.Now()) - if err != nil { - return nil, err - } - if len(keys.Keys) == 0 { - logging.WithFields("instanceID", authz.GetInstance(ctx).InstanceID()). - Info("There's no active signing key and automatic rotation is not supported for back channel logout." + - "Please enable the webkey management feature on your instance") - return nil, zerrors.ThrowPreconditionFailed(nil, "HANDL-DF3nf", "no active signing key") - } - return zoidc.PrivateKeyToSigningKey(zoidc.SelectSigningKey(keys.Keys), u.keyEncryptionAlg) -} - func (u *backChannelLogoutNotifier) sendLogoutToken(ctx context.Context, oidcSession *backChannelLogoutOIDCSessions, e eventstore.Event, getSigner zoidc.SignerFunc) error { token, err := u.logoutToken(ctx, oidcSession, getSigner) if err != nil { diff --git a/internal/notification/handlers/mock/commands.mock.go b/internal/notification/handlers/mock/commands.mock.go index 7d41c30f30..ec327de8e8 100644 --- a/internal/notification/handlers/mock/commands.mock.go +++ b/internal/notification/handlers/mock/commands.mock.go @@ -23,6 +23,7 @@ import ( type MockCommands struct { ctrl *gomock.Controller recorder *MockCommandsMockRecorder + isgomock struct{} } // MockCommandsMockRecorder is the mock recorder for MockCommands. @@ -43,197 +44,197 @@ func (m *MockCommands) EXPECT() *MockCommandsMockRecorder { } // HumanEmailVerificationCodeSent mocks base method. -func (m *MockCommands) HumanEmailVerificationCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanEmailVerificationCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanEmailVerificationCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // HumanEmailVerificationCodeSent indicates an expected call of HumanEmailVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanEmailVerificationCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanEmailVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanEmailVerificationCodeSent), ctx, orgID, userID) } // HumanInitCodeSent mocks base method. -func (m *MockCommands) HumanInitCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanInitCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanInitCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanInitCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // HumanInitCodeSent indicates an expected call of HumanInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanInitCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanInitCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanInitCodeSent), ctx, orgID, userID) } // HumanOTPEmailCodeSent mocks base method. -func (m *MockCommands) HumanOTPEmailCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) HumanOTPEmailCodeSent(ctx context.Context, userID, resourceOwner string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "HumanOTPEmailCodeSent", ctx, userID, resourceOwner) ret0, _ := ret[0].(error) return ret0 } // HumanOTPEmailCodeSent indicates an expected call of HumanOTPEmailCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPEmailCodeSent(ctx, userID, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPEmailCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPEmailCodeSent), ctx, userID, resourceOwner) } // HumanOTPSMSCodeSent mocks base method. -func (m *MockCommands) HumanOTPSMSCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanOTPSMSCodeSent(ctx context.Context, userID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanOTPSMSCodeSent", ctx, userID, resourceOwner, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // HumanOTPSMSCodeSent indicates an expected call of HumanOTPSMSCodeSent. -func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanOTPSMSCodeSent(ctx, userID, resourceOwner, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanOTPSMSCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanOTPSMSCodeSent), ctx, userID, resourceOwner, generatorInfo) } // HumanPasswordlessInitCodeSent mocks base method. -func (m *MockCommands) HumanPasswordlessInitCodeSent(arg0 context.Context, arg1, arg2, arg3 string) error { +func (m *MockCommands) HumanPasswordlessInitCodeSent(ctx context.Context, userID, resourceOwner, codeID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanPasswordlessInitCodeSent", ctx, userID, resourceOwner, codeID) ret0, _ := ret[0].(error) return ret0 } // HumanPasswordlessInitCodeSent indicates an expected call of HumanPasswordlessInitCodeSent. -func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPasswordlessInitCodeSent(ctx, userID, resourceOwner, codeID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPasswordlessInitCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPasswordlessInitCodeSent), ctx, userID, resourceOwner, codeID) } // HumanPhoneVerificationCodeSent mocks base method. -func (m *MockCommands) HumanPhoneVerificationCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) HumanPhoneVerificationCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "HumanPhoneVerificationCodeSent", ctx, orgID, userID, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // HumanPhoneVerificationCodeSent indicates an expected call of HumanPhoneVerificationCodeSent. -func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) HumanPhoneVerificationCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HumanPhoneVerificationCodeSent", reflect.TypeOf((*MockCommands)(nil).HumanPhoneVerificationCodeSent), ctx, orgID, userID, generatorInfo) } // InviteCodeSent mocks base method. -func (m *MockCommands) InviteCodeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) InviteCodeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InviteCodeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "InviteCodeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // InviteCodeSent indicates an expected call of InviteCodeSent. -func (mr *MockCommandsMockRecorder) InviteCodeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) InviteCodeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InviteCodeSent", reflect.TypeOf((*MockCommands)(nil).InviteCodeSent), ctx, orgID, userID) } // MilestonePushed mocks base method. -func (m *MockCommands) MilestonePushed(arg0 context.Context, arg1 string, arg2 milestone.Type, arg3 []string) error { +func (m *MockCommands) MilestonePushed(ctx context.Context, instanceID string, msType milestone.Type, endpoints []string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MilestonePushed", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "MilestonePushed", ctx, instanceID, msType, endpoints) ret0, _ := ret[0].(error) return ret0 } // MilestonePushed indicates an expected call of MilestonePushed. -func (mr *MockCommandsMockRecorder) MilestonePushed(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) MilestonePushed(ctx, instanceID, msType, endpoints any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MilestonePushed", reflect.TypeOf((*MockCommands)(nil).MilestonePushed), ctx, instanceID, msType, endpoints) } // OTPEmailSent mocks base method. -func (m *MockCommands) OTPEmailSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) OTPEmailSent(ctx context.Context, sessionID, resourceOwner string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPEmailSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "OTPEmailSent", ctx, sessionID, resourceOwner) ret0, _ := ret[0].(error) return ret0 } // OTPEmailSent indicates an expected call of OTPEmailSent. -func (mr *MockCommandsMockRecorder) OTPEmailSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPEmailSent(ctx, sessionID, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPEmailSent", reflect.TypeOf((*MockCommands)(nil).OTPEmailSent), ctx, sessionID, resourceOwner) } // OTPSMSSent mocks base method. -func (m *MockCommands) OTPSMSSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) OTPSMSSent(ctx context.Context, sessionID, resourceOwner string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "OTPSMSSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "OTPSMSSent", ctx, sessionID, resourceOwner, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // OTPSMSSent indicates an expected call of OTPSMSSent. -func (mr *MockCommandsMockRecorder) OTPSMSSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) OTPSMSSent(ctx, sessionID, resourceOwner, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OTPSMSSent", reflect.TypeOf((*MockCommands)(nil).OTPSMSSent), ctx, sessionID, resourceOwner, generatorInfo) } // PasswordChangeSent mocks base method. -func (m *MockCommands) PasswordChangeSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) PasswordChangeSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordChangeSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "PasswordChangeSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // PasswordChangeSent indicates an expected call of PasswordChangeSent. -func (mr *MockCommandsMockRecorder) PasswordChangeSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordChangeSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordChangeSent", reflect.TypeOf((*MockCommands)(nil).PasswordChangeSent), ctx, orgID, userID) } // PasswordCodeSent mocks base method. -func (m *MockCommands) PasswordCodeSent(arg0 context.Context, arg1, arg2 string, arg3 *senders.CodeGeneratorInfo) error { +func (m *MockCommands) PasswordCodeSent(ctx context.Context, orgID, userID string, generatorInfo *senders.CodeGeneratorInfo) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PasswordCodeSent", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "PasswordCodeSent", ctx, orgID, userID, generatorInfo) ret0, _ := ret[0].(error) return ret0 } // PasswordCodeSent indicates an expected call of PasswordCodeSent. -func (mr *MockCommandsMockRecorder) PasswordCodeSent(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) PasswordCodeSent(ctx, orgID, userID, generatorInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PasswordCodeSent", reflect.TypeOf((*MockCommands)(nil).PasswordCodeSent), ctx, orgID, userID, generatorInfo) } // UsageNotificationSent mocks base method. -func (m *MockCommands) UsageNotificationSent(arg0 context.Context, arg1 *quota.NotificationDueEvent) error { +func (m *MockCommands) UsageNotificationSent(ctx context.Context, dueEvent *quota.NotificationDueEvent) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UsageNotificationSent", arg0, arg1) + ret := m.ctrl.Call(m, "UsageNotificationSent", ctx, dueEvent) ret0, _ := ret[0].(error) return ret0 } // UsageNotificationSent indicates an expected call of UsageNotificationSent. -func (mr *MockCommandsMockRecorder) UsageNotificationSent(arg0, arg1 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UsageNotificationSent(ctx, dueEvent any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UsageNotificationSent", reflect.TypeOf((*MockCommands)(nil).UsageNotificationSent), ctx, dueEvent) } // UserDomainClaimedSent mocks base method. -func (m *MockCommands) UserDomainClaimedSent(arg0 context.Context, arg1, arg2 string) error { +func (m *MockCommands) UserDomainClaimedSent(ctx context.Context, orgID, userID string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UserDomainClaimedSent", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "UserDomainClaimedSent", ctx, orgID, userID) ret0, _ := ret[0].(error) return ret0 } // UserDomainClaimedSent indicates an expected call of UserDomainClaimedSent. -func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockCommandsMockRecorder) UserDomainClaimedSent(ctx, orgID, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UserDomainClaimedSent", reflect.TypeOf((*MockCommands)(nil).UserDomainClaimedSent), ctx, orgID, userID) } diff --git a/internal/notification/handlers/mock/queries.mock.go b/internal/notification/handlers/mock/queries.mock.go index 670d3f3896..2cf53d1b2a 100644 --- a/internal/notification/handlers/mock/queries.mock.go +++ b/internal/notification/handlers/mock/queries.mock.go @@ -12,7 +12,6 @@ package mock import ( context "context" reflect "reflect" - time "time" jose "github.com/go-jose/go-jose/v4" authz "github.com/zitadel/zitadel/internal/api/authz" @@ -26,6 +25,7 @@ import ( type MockQueries struct { ctrl *gomock.Controller recorder *MockQueriesMockRecorder + isgomock struct{} } // MockQueriesMockRecorder is the mock recorder for MockQueries. @@ -60,240 +60,225 @@ func (mr *MockQueriesMockRecorder) ActiveInstances() *gomock.Call { } // ActiveLabelPolicyByOrg mocks base method. -func (m *MockQueries) ActiveLabelPolicyByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.LabelPolicy, error) { +func (m *MockQueries) ActiveLabelPolicyByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.LabelPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "ActiveLabelPolicyByOrg", ctx, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.LabelPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // ActiveLabelPolicyByOrg indicates an expected call of ActiveLabelPolicyByOrg. -func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) ActiveLabelPolicyByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), arg0, arg1, arg2) -} - -// ActivePrivateSigningKey mocks base method. -func (m *MockQueries) ActivePrivateSigningKey(arg0 context.Context, arg1 time.Time) (*query.PrivateKeys, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActivePrivateSigningKey", arg0, arg1) - ret0, _ := ret[0].(*query.PrivateKeys) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ActivePrivateSigningKey indicates an expected call of ActivePrivateSigningKey. -func (mr *MockQueriesMockRecorder) ActivePrivateSigningKey(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivePrivateSigningKey", reflect.TypeOf((*MockQueries)(nil).ActivePrivateSigningKey), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveLabelPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).ActiveLabelPolicyByOrg), ctx, orgID, withOwnerRemoved) } // CustomTextListByTemplate mocks base method. -func (m *MockQueries) CustomTextListByTemplate(arg0 context.Context, arg1, arg2 string, arg3 bool) (*query.CustomTexts, error) { +func (m *MockQueries) CustomTextListByTemplate(ctx context.Context, aggregateID, template string, withOwnerRemoved bool) (*query.CustomTexts, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CustomTextListByTemplate", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "CustomTextListByTemplate", ctx, aggregateID, template, withOwnerRemoved) ret0, _ := ret[0].(*query.CustomTexts) ret1, _ := ret[1].(error) return ret0, ret1 } // CustomTextListByTemplate indicates an expected call of CustomTextListByTemplate. -func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) CustomTextListByTemplate(ctx, aggregateID, template, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CustomTextListByTemplate", reflect.TypeOf((*MockQueries)(nil).CustomTextListByTemplate), ctx, aggregateID, template, withOwnerRemoved) } // GetActiveSigningWebKey mocks base method. -func (m *MockQueries) GetActiveSigningWebKey(arg0 context.Context) (*jose.JSONWebKey, error) { +func (m *MockQueries) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetActiveSigningWebKey", arg0) + ret := m.ctrl.Call(m, "GetActiveSigningWebKey", ctx) ret0, _ := ret[0].(*jose.JSONWebKey) ret1, _ := ret[1].(error) return ret0, ret1 } // GetActiveSigningWebKey indicates an expected call of GetActiveSigningWebKey. -func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetActiveSigningWebKey(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActiveSigningWebKey", reflect.TypeOf((*MockQueries)(nil).GetActiveSigningWebKey), ctx) } // GetDefaultLanguage mocks base method. -func (m *MockQueries) GetDefaultLanguage(arg0 context.Context) language.Tag { +func (m *MockQueries) GetDefaultLanguage(ctx context.Context) language.Tag { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetDefaultLanguage", arg0) + ret := m.ctrl.Call(m, "GetDefaultLanguage", ctx) ret0, _ := ret[0].(language.Tag) return ret0 } // GetDefaultLanguage indicates an expected call of GetDefaultLanguage. -func (mr *MockQueriesMockRecorder) GetDefaultLanguage(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetDefaultLanguage(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDefaultLanguage", reflect.TypeOf((*MockQueries)(nil).GetDefaultLanguage), ctx) } // GetInstanceRestrictions mocks base method. -func (m *MockQueries) GetInstanceRestrictions(arg0 context.Context) (query.Restrictions, error) { +func (m *MockQueries) GetInstanceRestrictions(ctx context.Context) (query.Restrictions, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetInstanceRestrictions", arg0) + ret := m.ctrl.Call(m, "GetInstanceRestrictions", ctx) ret0, _ := ret[0].(query.Restrictions) ret1, _ := ret[1].(error) return ret0, ret1 } // GetInstanceRestrictions indicates an expected call of GetInstanceRestrictions. -func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(arg0 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetInstanceRestrictions(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), arg0) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInstanceRestrictions", reflect.TypeOf((*MockQueries)(nil).GetInstanceRestrictions), ctx) } // GetNotifyUserByID mocks base method. -func (m *MockQueries) GetNotifyUserByID(arg0 context.Context, arg1 bool, arg2 string) (*query.NotifyUser, error) { +func (m *MockQueries) GetNotifyUserByID(ctx context.Context, shouldTriggered bool, userID string) (*query.NotifyUser, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetNotifyUserByID", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetNotifyUserByID", ctx, shouldTriggered, userID) ret0, _ := ret[0].(*query.NotifyUser) ret1, _ := ret[1].(error) return ret0, ret1 } // GetNotifyUserByID indicates an expected call of GetNotifyUserByID. -func (mr *MockQueriesMockRecorder) GetNotifyUserByID(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) GetNotifyUserByID(ctx, shouldTriggered, userID any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotifyUserByID", reflect.TypeOf((*MockQueries)(nil).GetNotifyUserByID), ctx, shouldTriggered, userID) } // InstanceByID mocks base method. -func (m *MockQueries) InstanceByID(arg0 context.Context, arg1 string) (authz.Instance, error) { +func (m *MockQueries) InstanceByID(ctx context.Context, id string) (authz.Instance, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "InstanceByID", arg0, arg1) + ret := m.ctrl.Call(m, "InstanceByID", ctx, id) ret0, _ := ret[0].(authz.Instance) ret1, _ := ret[1].(error) return ret0, ret1 } // InstanceByID indicates an expected call of InstanceByID. -func (mr *MockQueriesMockRecorder) InstanceByID(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) InstanceByID(ctx, id any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstanceByID", reflect.TypeOf((*MockQueries)(nil).InstanceByID), ctx, id) } // MailTemplateByOrg mocks base method. -func (m *MockQueries) MailTemplateByOrg(arg0 context.Context, arg1 string, arg2 bool) (*query.MailTemplate, error) { +func (m *MockQueries) MailTemplateByOrg(ctx context.Context, orgID string, withOwnerRemoved bool) (*query.MailTemplate, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "MailTemplateByOrg", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "MailTemplateByOrg", ctx, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.MailTemplate) ret1, _ := ret[1].(error) return ret0, ret1 } // MailTemplateByOrg indicates an expected call of MailTemplateByOrg. -func (mr *MockQueriesMockRecorder) MailTemplateByOrg(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) MailTemplateByOrg(ctx, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MailTemplateByOrg", reflect.TypeOf((*MockQueries)(nil).MailTemplateByOrg), ctx, orgID, withOwnerRemoved) } // NotificationPolicyByOrg mocks base method. -func (m *MockQueries) NotificationPolicyByOrg(arg0 context.Context, arg1 bool, arg2 string, arg3 bool) (*query.NotificationPolicy, error) { +func (m *MockQueries) NotificationPolicyByOrg(ctx context.Context, shouldTriggerBulk bool, orgID string, withOwnerRemoved bool) (*query.NotificationPolicy, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationPolicyByOrg", arg0, arg1, arg2, arg3) + ret := m.ctrl.Call(m, "NotificationPolicyByOrg", ctx, shouldTriggerBulk, orgID, withOwnerRemoved) ret0, _ := ret[0].(*query.NotificationPolicy) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationPolicyByOrg indicates an expected call of NotificationPolicyByOrg. -func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(arg0, arg1, arg2, arg3 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationPolicyByOrg(ctx, shouldTriggerBulk, orgID, withOwnerRemoved any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), arg0, arg1, arg2, arg3) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationPolicyByOrg", reflect.TypeOf((*MockQueries)(nil).NotificationPolicyByOrg), ctx, shouldTriggerBulk, orgID, withOwnerRemoved) } // NotificationProviderByIDAndType mocks base method. -func (m *MockQueries) NotificationProviderByIDAndType(arg0 context.Context, arg1 string, arg2 domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { +func (m *MockQueries) NotificationProviderByIDAndType(ctx context.Context, aggID string, providerType domain.NotificationProviderType) (*query.DebugNotificationProvider, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "NotificationProviderByIDAndType", ctx, aggID, providerType) ret0, _ := ret[0].(*query.DebugNotificationProvider) ret1, _ := ret[1].(error) return ret0, ret1 } // NotificationProviderByIDAndType indicates an expected call of NotificationProviderByIDAndType. -func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) NotificationProviderByIDAndType(ctx, aggID, providerType any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NotificationProviderByIDAndType", reflect.TypeOf((*MockQueries)(nil).NotificationProviderByIDAndType), ctx, aggID, providerType) } // SMSProviderConfigActive mocks base method. -func (m *MockQueries) SMSProviderConfigActive(arg0 context.Context, arg1 string) (*query.SMSConfig, error) { +func (m *MockQueries) SMSProviderConfigActive(ctx context.Context, resourceOwner string) (*query.SMSConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMSProviderConfigActive", arg0, arg1) + ret := m.ctrl.Call(m, "SMSProviderConfigActive", ctx, resourceOwner) ret0, _ := ret[0].(*query.SMSConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMSProviderConfigActive indicates an expected call of SMSProviderConfigActive. -func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMSProviderConfigActive(ctx, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMSProviderConfigActive", reflect.TypeOf((*MockQueries)(nil).SMSProviderConfigActive), ctx, resourceOwner) } // SMTPConfigActive mocks base method. -func (m *MockQueries) SMTPConfigActive(arg0 context.Context, arg1 string) (*query.SMTPConfig, error) { +func (m *MockQueries) SMTPConfigActive(ctx context.Context, resourceOwner string) (*query.SMTPConfig, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SMTPConfigActive", arg0, arg1) + ret := m.ctrl.Call(m, "SMTPConfigActive", ctx, resourceOwner) ret0, _ := ret[0].(*query.SMTPConfig) ret1, _ := ret[1].(error) return ret0, ret1 } // SMTPConfigActive indicates an expected call of SMTPConfigActive. -func (mr *MockQueriesMockRecorder) SMTPConfigActive(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SMTPConfigActive(ctx, resourceOwner any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SMTPConfigActive", reflect.TypeOf((*MockQueries)(nil).SMTPConfigActive), ctx, resourceOwner) } // SearchInstanceDomains mocks base method. -func (m *MockQueries) SearchInstanceDomains(arg0 context.Context, arg1 *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { +func (m *MockQueries) SearchInstanceDomains(ctx context.Context, queries *query.InstanceDomainSearchQueries) (*query.InstanceDomains, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchInstanceDomains", arg0, arg1) + ret := m.ctrl.Call(m, "SearchInstanceDomains", ctx, queries) ret0, _ := ret[0].(*query.InstanceDomains) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchInstanceDomains indicates an expected call of SearchInstanceDomains. -func (mr *MockQueriesMockRecorder) SearchInstanceDomains(arg0, arg1 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchInstanceDomains(ctx, queries any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchInstanceDomains", reflect.TypeOf((*MockQueries)(nil).SearchInstanceDomains), ctx, queries) } // SearchMilestones mocks base method. -func (m *MockQueries) SearchMilestones(arg0 context.Context, arg1 []string, arg2 *query.MilestonesSearchQueries) (*query.Milestones, error) { +func (m *MockQueries) SearchMilestones(ctx context.Context, instanceIDs []string, queries *query.MilestonesSearchQueries) (*query.Milestones, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SearchMilestones", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "SearchMilestones", ctx, instanceIDs, queries) ret0, _ := ret[0].(*query.Milestones) ret1, _ := ret[1].(error) return ret0, ret1 } // SearchMilestones indicates an expected call of SearchMilestones. -func (mr *MockQueriesMockRecorder) SearchMilestones(arg0, arg1, arg2 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SearchMilestones(ctx, instanceIDs, queries any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchMilestones", reflect.TypeOf((*MockQueries)(nil).SearchMilestones), ctx, instanceIDs, queries) } // SessionByID mocks base method. -func (m *MockQueries) SessionByID(arg0 context.Context, arg1 bool, arg2, arg3 string, arg4 domain.PermissionCheck) (*query.Session, error) { +func (m *MockQueries) SessionByID(ctx context.Context, shouldTriggerBulk bool, id, sessionToken string, check domain.PermissionCheck) (*query.Session, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SessionByID", arg0, arg1, arg2, arg3, arg4) + ret := m.ctrl.Call(m, "SessionByID", ctx, shouldTriggerBulk, id, sessionToken, check) ret0, _ := ret[0].(*query.Session) ret1, _ := ret[1].(error) return ret0, ret1 } // SessionByID indicates an expected call of SessionByID. -func (mr *MockQueriesMockRecorder) SessionByID(arg0, arg1, arg2, arg3, arg4 any) *gomock.Call { +func (mr *MockQueriesMockRecorder) SessionByID(ctx, shouldTriggerBulk, id, sessionToken, check any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), arg0, arg1, arg2, arg3, arg4) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SessionByID", reflect.TypeOf((*MockQueries)(nil).SessionByID), ctx, shouldTriggerBulk, id, sessionToken, check) } diff --git a/internal/notification/handlers/mock/queue.mock.go b/internal/notification/handlers/mock/queue.mock.go index e1387595db..e9cf3efed1 100644 --- a/internal/notification/handlers/mock/queue.mock.go +++ b/internal/notification/handlers/mock/queue.mock.go @@ -22,6 +22,7 @@ import ( type MockQueue struct { ctrl *gomock.Controller recorder *MockQueueMockRecorder + isgomock struct{} } // MockQueueMockRecorder is the mock recorder for MockQueue. @@ -42,10 +43,10 @@ func (m *MockQueue) EXPECT() *MockQueueMockRecorder { } // Insert mocks base method. -func (m *MockQueue) Insert(arg0 context.Context, arg1 river.JobArgs, arg2 ...queue.InsertOpt) error { +func (m *MockQueue) Insert(ctx context.Context, args river.JobArgs, opts ...queue.InsertOpt) error { m.ctrl.T.Helper() - varargs := []any{arg0, arg1} - for _, a := range arg2 { + varargs := []any{ctx, args} + for _, a := range opts { varargs = append(varargs, a) } ret := m.ctrl.Call(m, "Insert", varargs...) @@ -54,8 +55,8 @@ func (m *MockQueue) Insert(arg0 context.Context, arg1 river.JobArgs, arg2 ...que } // Insert indicates an expected call of Insert. -func (mr *MockQueueMockRecorder) Insert(arg0, arg1 any, arg2 ...any) *gomock.Call { +func (mr *MockQueueMockRecorder) Insert(ctx, args any, opts ...any) *gomock.Call { mr.mock.ctrl.T.Helper() - varargs := append([]any{arg0, arg1}, arg2...) + varargs := append([]any{ctx, args}, opts...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Insert", reflect.TypeOf((*MockQueue)(nil).Insert), varargs...) } diff --git a/internal/notification/handlers/queries.go b/internal/notification/handlers/queries.go index a3d68e4797..d9ff1b4201 100644 --- a/internal/notification/handlers/queries.go +++ b/internal/notification/handlers/queries.go @@ -2,7 +2,6 @@ package handlers import ( "context" - "time" "github.com/go-jose/go-jose/v4" "golang.org/x/text/language" @@ -30,7 +29,6 @@ type Queries interface { GetInstanceRestrictions(ctx context.Context) (restrictions query.Restrictions, err error) InstanceByID(ctx context.Context, id string) (instance authz.Instance, err error) GetActiveSigningWebKey(ctx context.Context) (*jose.JSONWebKey, error) - ActivePrivateSigningKey(ctx context.Context, t time.Time) (keys *query.PrivateKeys, err error) ActiveInstances() []string } diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 501cfc4e9c..9e0081a542 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -14,7 +14,6 @@ type InstanceFeatures struct { UserSchema FeatureSource[bool] TokenExchange FeatureSource[bool] ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - WebKey FeatureSource[bool] DebugOIDCParentError FeatureSource[bool] OIDCSingleV1SessionTermination FeatureSource[bool] DisableUserTokenEvent FeatureSource[bool] diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index 7130044fbf..a30009e9ee 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -67,7 +67,6 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, - feature_v2.InstanceWebKeyEventType, feature_v2.InstanceDebugOIDCParentErrorEventType, feature_v2.InstanceOIDCSingleV1SessionTerminationEventType, feature_v2.InstanceDisableUserTokenEvent, @@ -121,8 +120,6 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ features.TokenExchange.set(level, event.Value) case feature.KeyImprovedPerformance: features.ImprovedPerformance.set(level, event.Value) - case feature.KeyWebKey: - features.WebKey.set(level, event.Value) case feature.KeyDebugOIDCParentError: features.DebugOIDCParentError.set(level, event.Value) case feature.KeyOIDCSingleV1SessionTermination: diff --git a/internal/query/key.go b/internal/query/key.go index 4831d88654..e7b81bb951 100644 --- a/internal/query/key.go +++ b/internal/query/key.go @@ -1,20 +1,10 @@ package query import ( - "context" - "crypto/rsa" - "database/sql" "time" - sq "github.com/Masterminds/squirrel" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/query/projection" - "github.com/zitadel/zitadel/internal/repository/keypair" - "github.com/zitadel/zitadel/internal/telemetry/tracing" - "github.com/zitadel/zitadel/internal/zerrors" ) type Key interface { @@ -36,11 +26,6 @@ type PublicKey interface { Key() interface{} } -type PrivateKeys struct { - SearchResponse - Keys []PrivateKey -} - type PublicKeys struct { SearchResponse Keys []PublicKey @@ -72,34 +57,6 @@ func (k *key) Sequence() uint64 { return k.sequence } -type privateKey struct { - key - expiry time.Time - privateKey *crypto.CryptoValue -} - -func (k *privateKey) Expiry() time.Time { - return k.expiry -} - -func (k *privateKey) Key() *crypto.CryptoValue { - return k.privateKey -} - -type rsaPublicKey struct { - key - expiry time.Time - publicKey *rsa.PublicKey -} - -func (r *rsaPublicKey) Expiry() time.Time { - return r.expiry -} - -func (r *rsaPublicKey) Key() interface{} { - return r.publicKey -} - var ( keyTable = table{ name: projection.KeyProjectionTable, @@ -157,277 +114,3 @@ var ( table: keyPrivateTable, } ) - -var ( - keyPublicTable = table{ - name: projection.KeyPublicTable, - instanceIDCol: projection.KeyPrivateColumnInstanceID, - } - KeyPublicColID = Column{ - name: projection.KeyPublicColumnID, - table: keyPublicTable, - } - KeyPublicColExpiry = Column{ - name: projection.KeyPublicColumnExpiry, - table: keyPublicTable, - } - KeyPublicColKey = Column{ - name: projection.KeyPublicColumnKey, - table: keyPublicTable, - } -) - -func (q *Queries) ActivePublicKeys(ctx context.Context, t time.Time) (keys *PublicKeys, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - query, scan := preparePublicKeysQuery() - if t.IsZero() { - t = time.Now() - } - stmt, args, err := query.Where( - sq.And{ - sq.Eq{KeyColInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()}, - sq.Gt{KeyPublicColExpiry.identifier(): t}, - }).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-SDFfg", "Errors.Query.SQLStatement") - } - - err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { - keys, err = scan(rows) - return err - }, stmt, args...) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Sghn4", "Errors.Internal") - } - - keys.State, err = q.latestState(ctx, keyTable) - if !zerrors.IsNotFound(err) { - return keys, err - } - return keys, nil -} - -func (q *Queries) ActivePrivateSigningKey(ctx context.Context, t time.Time) (keys *PrivateKeys, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - stmt, scan := preparePrivateKeysQuery() - if t.IsZero() { - t = time.Now() - } - query, args, err := stmt.Where( - sq.And{ - sq.Eq{ - KeyColUse.identifier(): crypto.KeyUsageSigning, - KeyColInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), - }, - sq.Gt{KeyPrivateColExpiry.identifier(): t}, - }).OrderBy(KeyPrivateColExpiry.identifier()).ToSql() - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-SDff2", "Errors.Query.SQLStatement") - } - - err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { - keys, err = scan(rows) - return err - }, query, args...) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-WRFG4", "Errors.Internal") - } - keys.State, err = q.latestState(ctx, keyTable) - if !zerrors.IsNotFound(err) { - return keys, err - } - return keys, nil -} - -func preparePublicKeysQuery() (sq.SelectBuilder, func(*sql.Rows) (*PublicKeys, error)) { - return sq.Select( - KeyColID.identifier(), - KeyColCreationDate.identifier(), - KeyColChangeDate.identifier(), - KeyColSequence.identifier(), - KeyColResourceOwner.identifier(), - KeyColAlgorithm.identifier(), - KeyColUse.identifier(), - KeyPublicColExpiry.identifier(), - KeyPublicColKey.identifier(), - countColumn.identifier(), - ).From(keyTable.identifier()). - LeftJoin(join(KeyPublicColID, KeyColID)). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*PublicKeys, error) { - keys := make([]PublicKey, 0) - var count uint64 - for rows.Next() { - k := new(rsaPublicKey) - var keyValue []byte - err := rows.Scan( - &k.id, - &k.creationDate, - &k.changeDate, - &k.sequence, - &k.resourceOwner, - &k.algorithm, - &k.use, - &k.expiry, - &keyValue, - &count, - ) - if err != nil { - return nil, err - } - k.publicKey, err = crypto.BytesToPublicKey(keyValue) - if err != nil { - return nil, err - } - keys = append(keys, k) - } - - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-rKd6k", "Errors.Query.CloseRows") - } - - return &PublicKeys{ - Keys: keys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil - } -} - -func preparePrivateKeysQuery() (sq.SelectBuilder, func(*sql.Rows) (*PrivateKeys, error)) { - return sq.Select( - KeyColID.identifier(), - KeyColCreationDate.identifier(), - KeyColChangeDate.identifier(), - KeyColSequence.identifier(), - KeyColResourceOwner.identifier(), - KeyColAlgorithm.identifier(), - KeyColUse.identifier(), - KeyPrivateColExpiry.identifier(), - KeyPrivateColKey.identifier(), - countColumn.identifier(), - ).From(keyTable.identifier()). - LeftJoin(join(KeyPrivateColID, KeyColID)). - PlaceholderFormat(sq.Dollar), - func(rows *sql.Rows) (*PrivateKeys, error) { - keys := make([]PrivateKey, 0) - var count uint64 - for rows.Next() { - k := new(privateKey) - err := rows.Scan( - &k.id, - &k.creationDate, - &k.changeDate, - &k.sequence, - &k.resourceOwner, - &k.algorithm, - &k.use, - &k.expiry, - &k.privateKey, - &count, - ) - if err != nil { - return nil, err - } - keys = append(keys, k) - } - - if err := rows.Close(); err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-rKd6k", "Errors.Query.CloseRows") - } - - return &PrivateKeys{ - Keys: keys, - SearchResponse: SearchResponse{ - Count: count, - }, - }, nil - } -} - -type PublicKeyReadModel struct { - eventstore.ReadModel - - Algorithm string - Key *crypto.CryptoValue - Expiry time.Time - Usage crypto.KeyUsage -} - -func NewPublicKeyReadModel(keyID, resourceOwner string) *PublicKeyReadModel { - return &PublicKeyReadModel{ - ReadModel: eventstore.ReadModel{ - AggregateID: keyID, - ResourceOwner: resourceOwner, - }, - } -} - -func (wm *PublicKeyReadModel) AppendEvents(events ...eventstore.Event) { - wm.ReadModel.AppendEvents(events...) -} - -func (wm *PublicKeyReadModel) Reduce() error { - for _, event := range wm.Events { - switch e := event.(type) { - case *keypair.AddedEvent: - wm.Algorithm = e.Algorithm - wm.Key = e.PublicKey.Key - wm.Expiry = e.PublicKey.Expiry - wm.Usage = e.Usage - default: - } - } - return wm.ReadModel.Reduce() -} - -func (wm *PublicKeyReadModel) Query() *eventstore.SearchQueryBuilder { - return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). - AwaitOpenTransactions(). - ResourceOwner(wm.ResourceOwner). - AddQuery(). - AggregateTypes(keypair.AggregateType). - AggregateIDs(wm.AggregateID). - EventTypes(keypair.AddedEventType). - Builder() -} - -func (q *Queries) GetPublicKeyByID(ctx context.Context, keyID string) (_ PublicKey, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() - - model := NewPublicKeyReadModel(keyID, authz.GetInstance(ctx).InstanceID()) - if err := q.eventstore.FilterToQueryReducer(ctx, model); err != nil { - return nil, err - } - if model.Algorithm == "" || model.Key == nil { - return nil, zerrors.ThrowNotFound(err, "QUERY-Ahf7x", "Errors.Key.NotFound") - } - keyValue, err := crypto.Decrypt(model.Key, q.keyEncryptionAlgorithm) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Ie4oh", "Errors.Internal") - } - publicKey, err := crypto.BytesToPublicKey(keyValue) - if err != nil { - return nil, zerrors.ThrowInternal(err, "QUERY-Kai2Z", "Errors.Internal") - } - - return &rsaPublicKey{ - key: key{ - id: model.AggregateID, - creationDate: model.CreationDate, - changeDate: model.ChangeDate, - sequence: model.ProcessedSequence, - resourceOwner: model.ResourceOwner, - algorithm: model.Algorithm, - use: model.Usage, - }, - expiry: model.Expiry, - publicKey: publicKey, - }, nil -} diff --git a/internal/query/key_test.go b/internal/query/key_test.go deleted file mode 100644 index 7bc029fd7f..0000000000 --- a/internal/query/key_test.go +++ /dev/null @@ -1,453 +0,0 @@ -package query - -import ( - "context" - "crypto/rsa" - "database/sql" - "database/sql/driver" - "errors" - "fmt" - "io" - "math/big" - "regexp" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/crypto" - "github.com/zitadel/zitadel/internal/eventstore" - key_repo "github.com/zitadel/zitadel/internal/repository/keypair" - "github.com/zitadel/zitadel/internal/zerrors" -) - -var ( - preparePublicKeysStmt = `SELECT projections.keys4.id,` + - ` projections.keys4.creation_date,` + - ` projections.keys4.change_date,` + - ` projections.keys4.sequence,` + - ` projections.keys4.resource_owner,` + - ` projections.keys4.algorithm,` + - ` projections.keys4.use,` + - ` projections.keys4_public.expiry,` + - ` projections.keys4_public.key,` + - ` COUNT(*) OVER ()` + - ` FROM projections.keys4` + - ` LEFT JOIN projections.keys4_public ON projections.keys4.id = projections.keys4_public.id AND projections.keys4.instance_id = projections.keys4_public.instance_id` - preparePublicKeysCols = []string{ - "id", - "creation_date", - "change_date", - "sequence", - "resource_owner", - "algorithm", - "use", - "expiry", - "key", - "count", - } - - preparePrivateKeysStmt = `SELECT projections.keys4.id,` + - ` projections.keys4.creation_date,` + - ` projections.keys4.change_date,` + - ` projections.keys4.sequence,` + - ` projections.keys4.resource_owner,` + - ` projections.keys4.algorithm,` + - ` projections.keys4.use,` + - ` projections.keys4_private.expiry,` + - ` projections.keys4_private.key,` + - ` COUNT(*) OVER ()` + - ` FROM projections.keys4` + - ` LEFT JOIN projections.keys4_private ON projections.keys4.id = projections.keys4_private.id AND projections.keys4.instance_id = projections.keys4_private.instance_id` -) - -func Test_KeyPrepares(t *testing.T) { - type want struct { - sqlExpectations sqlExpectation - err checkErr - } - tests := []struct { - name string - prepare interface{} - want want - object interface{} - }{ - { - name: "preparePublicKeysQuery no result", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePublicKeysStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: &PublicKeys{Keys: []PublicKey{}}, - }, - { - name: "preparePublicKeysQuery found", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePublicKeysStmt), - preparePublicKeysCols, - [][]driver.Value{ - { - "key-id", - testNow, - testNow, - uint64(20211109), - "ro", - "RS256", - 0, - testNow, - []byte("-----BEGIN RSA PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsvX9P58JFxEs5C+L+H7W\nduFSWL5EPzber7C2m94klrSV6q0bAcrYQnGwFOlveThsY200hRbadKaKjHD7qIKH\nDEe0IY2PSRht33Jye52AwhkRw+M3xuQH/7R8LydnsNFk2KHpr5X2SBv42e37LjkE\nslKSaMRgJW+v0KZ30piY8QsdFRKKaVg5/Ajt1YToM1YVsdHXJ3vmXFMtypLdxwUD\ndIaLEX6pFUkU75KSuEQ/E2luT61Q3ta9kOWm9+0zvi7OMcbdekJT7mzcVnh93R1c\n13ZhQCLbh9A7si8jKFtaMWevjayrvqQABEcTN9N4Hoxcyg6l4neZtRDk75OMYcqm\nDQIDAQAB\n-----END RSA PUBLIC KEY-----\n"), - }, - }, - ), - }, - object: &PublicKeys{ - SearchResponse: SearchResponse{ - Count: 1, - }, - Keys: []PublicKey{ - &rsaPublicKey{ - key: key{ - id: "key-id", - creationDate: testNow, - changeDate: testNow, - sequence: 20211109, - resourceOwner: "ro", - algorithm: "RS256", - use: crypto.KeyUsageSigning, - }, - expiry: testNow, - publicKey: &rsa.PublicKey{ - E: 65537, - N: fromBase16("b2f5fd3f9f0917112ce42f8bf87ed676e15258be443f36deafb0b69bde2496b495eaad1b01cad84271b014e96f79386c636d348516da74a68a8c70fba882870c47b4218d8f49186ddf72727b9d80c21911c3e337c6e407ffb47c2f2767b0d164d8a1e9af95f6481bf8d9edfb2e3904b2529268c460256fafd0a677d29898f10b1d15128a695839fc08edd584e8335615b1d1d7277be65c532dca92ddc7050374868b117ea9154914ef9292b8443f13696e4fad50ded6bd90e5a6f7ed33be2ece31c6dd7a4253ee6cdc56787ddd1d5cd776614022db87d03bb22f23285b5a3167af8dacabbea40004471337d3781e8c5cca0ea5e27799b510e4ef938c61caa60d"), - }, - }, - }, - }, - }, - { - name: "preparePublicKeysQuery sql err", - prepare: preparePublicKeysQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(preparePublicKeysStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*PublicKeys)(nil), - }, - { - name: "preparePrivateKeysQuery no result", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePrivateKeysStmt), - nil, - nil, - ), - err: func(err error) (error, bool) { - if !zerrors.IsNotFound(err) { - return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false - } - return nil, true - }, - }, - object: &PrivateKeys{Keys: []PrivateKey{}}, - }, - { - name: "preparePrivateKeysQuery found", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueries( - regexp.QuoteMeta(preparePrivateKeysStmt), - preparePublicKeysCols, - [][]driver.Value{ - { - "key-id", - testNow, - testNow, - uint64(20211109), - "ro", - "RS256", - 0, - testNow, - []byte(`{"Algorithm": "enc", "Crypted": "cHJpdmF0ZUtleQ==", "CryptoType": 0, "KeyID": "id"}`), - }, - }, - ), - }, - object: &PrivateKeys{ - SearchResponse: SearchResponse{ - Count: 1, - }, - Keys: []PrivateKey{ - &privateKey{ - key: key{ - id: "key-id", - creationDate: testNow, - changeDate: testNow, - sequence: 20211109, - resourceOwner: "ro", - algorithm: "RS256", - use: crypto.KeyUsageSigning, - }, - expiry: testNow, - privateKey: &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("privateKey"), - }, - }, - }, - }, - }, - { - name: "preparePrivateKeysQuery sql err", - prepare: preparePrivateKeysQuery, - want: want{ - sqlExpectations: mockQueryErr( - regexp.QuoteMeta(preparePrivateKeysStmt), - sql.ErrConnDone, - ), - err: func(err error) (error, bool) { - if !errors.Is(err, sql.ErrConnDone) { - return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false - } - return nil, true - }, - }, - object: (*PrivateKeys)(nil), - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) - }) - } -} - -func fromBase16(base16 string) *big.Int { - i, ok := new(big.Int).SetString(base16, 16) - if !ok { - panic("bad number: " + base16) - } - return i -} - -const pubKey = `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs38btwb3c7r0tMaQpGvB -mY+mPwMU/LpfuPoC0k2t4RsKp0fv40SMl50CRrHgk395wch8PMPYbl3+8TtYAJuy -rFALIj3Ff1UcKIk0hOH5DDsfh7/q2wFuncTmS6bifYo8CfSq2vDGnM7nZnEvxY/M -fSydZdcmIqlkUpfQmtzExw9+tSe5Dxq6gn5JtlGgLgZGt69r5iMMrTEGhhVAXzNu -MZbmlCoBru+rC8ITlTX/0V1ZcsSbL8tYWhthyu9x6yjo1bH85wiVI4gs0MhU8f2a -+kjL/KGZbR14Ua2eo6tonBZLC5DHWM2TkYXgRCDPufjcgmzN0Lm91E4P8KvBcvly -6QIDAQAB ------END PUBLIC KEY----- -` - -func TestQueries_GetPublicKeyByID(t *testing.T) { - now := time.Now() - future := now.Add(time.Hour) - - tests := []struct { - name string - eventstore func(*testing.T) *eventstore.Eventstore - encryption func(*testing.T) *crypto.MockEncryptionAlgorithm - want *rsaPublicKey - wantErr error - }{ - { - name: "filter error", - eventstore: expectEventstore( - expectFilterError(io.ErrClosedPipe), - ), - wantErr: io.ErrClosedPipe, - }, - { - name: "not found error", - eventstore: expectEventstore( - expectFilter(), - ), - wantErr: zerrors.ThrowNotFound(nil, "QUERY-Ahf7x", "Errors.Key.NotFound"), - }, - { - name: "decrypt error", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{}) - return encryption - }, - wantErr: zerrors.ThrowInternal(nil, "QUERY-Ie4oh", "Errors.Internal"), - }, - { - name: "parse error", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{"keyID"}) - expect.Decrypt([]byte("public"), "keyID").Return([]byte("foo"), nil) - return encryption - }, - wantErr: zerrors.ThrowInternal(nil, "QUERY-Kai2Z", "Errors.Internal"), - }, - { - name: "success", - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher(key_repo.NewAddedEvent(context.Background(), - &eventstore.Aggregate{ - ID: "keyID", - Type: key_repo.AggregateType, - ResourceOwner: "instanceID", - InstanceID: "instanceID", - Version: key_repo.AggregateVersion, - }, - crypto.KeyUsageSigning, "alg", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("private"), - }, - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "alg", - KeyID: "keyID", - Crypted: []byte("public"), - }, - future, - future, - )), - ), - ), - encryption: func(t *testing.T) *crypto.MockEncryptionAlgorithm { - encryption := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) - expect := encryption.EXPECT() - expect.Algorithm().Return("alg") - expect.DecryptionKeyIDs().Return([]string{"keyID"}) - expect.Decrypt([]byte("public"), "keyID").Return([]byte(pubKey), nil) - return encryption - }, - want: &rsaPublicKey{ - key: key{ - id: "keyID", - resourceOwner: "instanceID", - algorithm: "alg", - use: crypto.KeyUsageSigning, - }, - expiry: future, - publicKey: func() *rsa.PublicKey { - publicKey, err := crypto.BytesToPublicKey([]byte(pubKey)) - if err != nil { - panic(err) - } - return publicKey - }(), - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - q := &Queries{ - eventstore: tt.eventstore(t), - } - if tt.encryption != nil { - q.keyEncryptionAlgorithm = tt.encryption(t) - } - ctx := authz.NewMockContext("instanceID", "orgID", "loginClient") - key, err := q.GetPublicKeyByID(ctx, "keyID") - if tt.wantErr != nil { - require.ErrorIs(t, err, tt.wantErr) - return - } - require.NoError(t, err) - require.NotNil(t, key) - - got := key.(*rsaPublicKey) - assert.WithinDuration(t, tt.want.expiry, got.expiry, time.Second) - tt.want.expiry = time.Time{} - got.expiry = time.Time{} - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 443353c2e5..3c33ff6fdf 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -80,10 +80,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceImprovedPerformanceEventType, Reduce: reduceInstanceSetFeature[[]feature.ImprovedPerformanceType], }, - { - Event: feature_v2.InstanceWebKeyEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceDebugOIDCParentErrorEventType, Reduce: reduceInstanceSetFeature[bool], diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 62fa568fca..25d0f270f6 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -24,7 +24,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceWebKeyEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDebugOIDCParentErrorEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceOIDCSingleV1SessionTerminationEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceDisableUserTokenEvent, eventstore.GenericEventMapper[SetEvent[bool]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index f75fae618b..a87042d72a 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -29,7 +29,6 @@ var ( InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) - InstanceWebKeyEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyWebKey) InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index 0455befb46..efbe5e3cdf 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 6, 8; + reserved "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -49,13 +49,6 @@ message SetInstanceFeaturesRequest{ } ]; - optional bool web_key = 8 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - optional bool debug_oidc_parent_error = 9 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -125,8 +118,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 4, 7, 9; + reserved "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -163,13 +156,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag web_key = 9 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - FeatureFlag debug_oidc_parent_error = 10 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 8028305fe4..7968668e50 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 6, 8; + reserved "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -49,13 +49,6 @@ message SetInstanceFeaturesRequest{ } ]; - optional bool web_key = 8 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - optional bool debug_oidc_parent_error = 9 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -91,8 +84,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 4, 7, 9; + reserved "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -129,13 +122,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag web_key = 9 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable the webkey/v3alpha API. The first time this feature is enabled, web keys are generated and activated."; - } - ]; - FeatureFlag debug_oidc_parent_error = 10 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; From 2691dae2b6355d3e962be791db28b88d4b763f98 Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Fri, 27 Jun 2025 17:25:44 +0200 Subject: [PATCH 107/123] feat: App API v2 (#10077) # Which Problems Are Solved This PR *partially* addresses #9450 . Specifically, it implements the resource based API for the apps. APIs for app keys ARE not part of this PR. # How the Problems Are Solved - `CreateApplication`, `PatchApplication` (update) and `RegenerateClientSecret` endpoints are now unique for all app types: API, SAML and OIDC apps. - All new endpoints have integration tests - All new endpoints are using permission checks V2 # Additional Changes - The `ListApplications` endpoint allows to do sorting (see protobuf for details) and filtering by app type (see protobuf). - SAML and OIDC update endpoint can now receive requests for partial updates # Additional Context Partially addresses #9450 --- cmd/start/start.go | 5 + internal/api/grpc/admin/export.go | 2 +- internal/api/grpc/app/v2beta/app.go | 208 +++ .../api/grpc/app/v2beta/convert/api_app.go | 60 + .../grpc/app/v2beta/convert/api_app_test.go | 149 ++ .../api/grpc/app/v2beta/convert/convert.go | 165 ++ .../grpc/app/v2beta/convert/convert_test.go | 520 ++++++ .../api/grpc/app/v2beta/convert/oidc_app.go | 291 ++++ .../grpc/app/v2beta/convert/oidc_app_test.go | 755 +++++++++ .../api/grpc/app/v2beta/convert/saml_app.go | 77 + .../grpc/app/v2beta/convert/saml_app_test.go | 256 +++ .../app/v2beta/integration_test/app_test.go | 1446 +++++++++++++++++ .../app/v2beta/integration_test/query_test.go | 575 +++++++ .../v2beta/integration_test/server_test.go | 205 +++ internal/api/grpc/app/v2beta/query.go | 37 + internal/api/grpc/app/v2beta/server.go | 57 + internal/api/grpc/filter/v2/converter.go | 23 + .../grpc/management/project_application.go | 10 +- .../project_application_converter.go | 64 +- .../eventsourcing/view/application.go | 2 +- internal/command/permission_checks.go | 8 + internal/command/project_application.go | 28 +- internal/command/project_application_api.go | 37 +- .../command/project_application_api_test.go | 21 +- internal/command/project_application_oidc.go | 71 +- .../command/project_application_oidc_model.go | 82 +- .../command/project_application_oidc_test.go | 279 ++-- internal/command/project_application_saml.go | 40 +- .../command/project_application_saml_model.go | 22 +- .../command/project_application_saml_test.go | 161 +- internal/command/project_application_test.go | 133 +- internal/command/project_converter.go | 43 +- internal/command/project_model.go | 12 +- internal/domain/application_oidc.go | 93 +- internal/domain/application_oidc_test.go | 57 +- internal/domain/application_saml.go | 13 +- internal/domain/permission.go | 3 + internal/integration/client.go | 3 + internal/project/model/oidc_config.go | 4 +- internal/query/app.go | 92 +- pkg/grpc/app/v2beta/application.go | 5 + proto/zitadel/app/v2beta/api.proto | 26 + proto/zitadel/app/v2beta/app.proto | 94 ++ proto/zitadel/app/v2beta/app_service.proto | 788 +++++++++ proto/zitadel/app/v2beta/login.proto | 18 + proto/zitadel/app/v2beta/oidc.proto | 166 ++ proto/zitadel/app/v2beta/saml.proto | 20 + proto/zitadel/management.proto | 222 +-- 48 files changed, 6845 insertions(+), 603 deletions(-) create mode 100644 internal/api/grpc/app/v2beta/app.go create mode 100644 internal/api/grpc/app/v2beta/convert/api_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/api_app_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/convert.go create mode 100644 internal/api/grpc/app/v2beta/convert/convert_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/oidc_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/oidc_app_test.go create mode 100644 internal/api/grpc/app/v2beta/convert/saml_app.go create mode 100644 internal/api/grpc/app/v2beta/convert/saml_app_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/app_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/query_test.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/server_test.go create mode 100644 internal/api/grpc/app/v2beta/query.go create mode 100644 internal/api/grpc/app/v2beta/server.go create mode 100644 pkg/grpc/app/v2beta/application.go create mode 100644 proto/zitadel/app/v2beta/api.proto create mode 100644 proto/zitadel/app/v2beta/app.proto create mode 100644 proto/zitadel/app/v2beta/app_service.proto create mode 100644 proto/zitadel/app/v2beta/login.proto create mode 100644 proto/zitadel/app/v2beta/oidc.proto create mode 100644 proto/zitadel/app/v2beta/saml.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 3c3b5cb3e0..dbd6289041 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -36,6 +36,7 @@ import ( internal_authz "github.com/zitadel/zitadel/internal/api/authz" action_v2_beta "github.com/zitadel/zitadel/internal/api/grpc/action/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/admin" + app "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/auth" feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" @@ -509,6 +510,10 @@ func startAPIs( if err := apis.RegisterService(ctx, debug_events.CreateServer(commands, queries)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, app.CreateServer(commands, queries, permissionCheck)); err != nil { + return nil, err + } + instanceInterceptor := middleware.InstanceInterceptor(queries, config.ExternalDomain, login.IgnoreInstanceEndpoints...) assetsCache := middleware.AssetsCacheInterceptor(config.AssetStorage.Cache.MaxAge, config.AssetStorage.Cache.SharedMaxAge) apis.RegisterHandlerOnPrefix(assets.HandlerPrefix, assets.NewHandler(commands, verifier, config.SystemAuthZ, config.InternalAuthZ, id.SonyFlakeGenerator(), store, queries, middleware.CallDurationHandler, instanceInterceptor.Handler, assetsCache.Handler, limitingAccessInterceptor.Handle)) diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index b5d36272d4..8024cd9d6e 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -783,7 +783,7 @@ func (s *Server) getProjectsAndApps(ctx context.Context, org string) ([]*v1_pb.D if err != nil { return nil, nil, nil, nil, nil, err } - apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, false) + apps, err := s.query.SearchApps(ctx, &query.AppSearchQueries{Queries: []query.SearchQuery{appSearch}}, nil) if err != nil { return nil, nil, nil, nil, nil, err } diff --git a/internal/api/grpc/app/v2beta/app.go b/internal/api/grpc/app/v2beta/app.go new file mode 100644 index 0000000000..48c602f454 --- /dev/null +++ b/internal/api/grpc/app/v2beta/app.go @@ -0,0 +1,208 @@ +package app + +import ( + "context" + "strings" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) CreateApplication(ctx context.Context, req *app.CreateApplicationRequest) (*app.CreateApplicationResponse, error) { + switch t := req.GetCreationRequestType().(type) { + case *app.CreateApplicationRequest_ApiRequest: + apiApp, err := s.command.AddAPIApplication(ctx, convert.CreateAPIApplicationRequestToDomain(req.GetName(), req.GetProjectId(), req.GetId(), t.ApiRequest), "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: apiApp.AppID, + CreationDate: timestamppb.New(apiApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_ApiResponse{ + ApiResponse: &app.CreateAPIApplicationResponse{ + ClientId: apiApp.ClientID, + ClientSecret: apiApp.ClientSecretString, + }, + }, + }, nil + + case *app.CreateApplicationRequest_OidcRequest: + oidcAppRequest, err := convert.CreateOIDCAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetOidcRequest()) + if err != nil { + return nil, err + } + + oidcApp, err := s.command.AddOIDCApplication(ctx, oidcAppRequest, "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: oidcApp.AppID, + CreationDate: timestamppb.New(oidcApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_OidcResponse{ + OidcResponse: &app.CreateOIDCApplicationResponse{ + ClientId: oidcApp.ClientID, + ClientSecret: oidcApp.ClientSecretString, + NoneCompliant: oidcApp.Compliance.NoneCompliant, + ComplianceProblems: convert.ComplianceProblemsToLocalizedMessages(oidcApp.Compliance.Problems), + }, + }, + }, nil + + case *app.CreateApplicationRequest_SamlRequest: + samlAppRequest, err := convert.CreateSAMLAppRequestToDomain(req.GetName(), req.GetProjectId(), req.GetSamlRequest()) + if err != nil { + return nil, err + } + + samlApp, err := s.command.AddSAMLApplication(ctx, samlAppRequest, "") + if err != nil { + return nil, err + } + + return &app.CreateApplicationResponse{ + AppId: samlApp.AppID, + CreationDate: timestamppb.New(samlApp.ChangeDate), + CreationResponseType: &app.CreateApplicationResponse_SamlResponse{ + SamlResponse: &app.CreateSAMLApplicationResponse{}, + }, + }, nil + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-0iiN46", "unknown app type") + } +} + +func (s *Server) UpdateApplication(ctx context.Context, req *app.UpdateApplicationRequest) (*app.UpdateApplicationResponse, error) { + var changedTime time.Time + + if name := strings.TrimSpace(req.GetName()); name != "" { + updatedDetails, err := s.command.UpdateApplicationName( + ctx, + req.GetProjectId(), + &domain.ChangeApp{ + AppID: req.GetId(), + AppName: name, + }, + "", + ) + if err != nil { + return nil, err + } + + changedTime = updatedDetails.EventDate + } + + switch t := req.GetUpdateRequestType().(type) { + case *app.UpdateApplicationRequest_ApiConfigurationRequest: + updatedAPIApp, err := s.command.UpdateAPIApplication(ctx, convert.UpdateAPIApplicationConfigurationRequestToDomain(req.GetId(), req.GetProjectId(), t.ApiConfigurationRequest), "") + if err != nil { + return nil, err + } + + changedTime = updatedAPIApp.ChangeDate + + case *app.UpdateApplicationRequest_OidcConfigurationRequest: + oidcApp, err := convert.UpdateOIDCAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.OidcConfigurationRequest) + if err != nil { + return nil, err + } + + updatedOIDCApp, err := s.command.UpdateOIDCApplication(ctx, oidcApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedOIDCApp.ChangeDate + + case *app.UpdateApplicationRequest_SamlConfigurationRequest: + samlApp, err := convert.UpdateSAMLAppConfigRequestToDomain(req.GetId(), req.GetProjectId(), t.SamlConfigurationRequest) + if err != nil { + return nil, err + } + + updatedSAMLApp, err := s.command.UpdateSAMLApplication(ctx, samlApp, "") + if err != nil { + return nil, err + } + + changedTime = updatedSAMLApp.ChangeDate + } + + return &app.UpdateApplicationResponse{ + ChangeDate: timestamppb.New(changedTime), + }, nil +} + +func (s *Server) DeleteApplication(ctx context.Context, req *app.DeleteApplicationRequest) (*app.DeleteApplicationResponse, error) { + details, err := s.command.RemoveApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.DeleteApplicationResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }, nil +} + +func (s *Server) DeactivateApplication(ctx context.Context, req *app.DeactivateApplicationRequest) (*app.DeactivateApplicationResponse, error) { + details, err := s.command.DeactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.DeactivateApplicationResponse{ + DeactivationDate: timestamppb.New(details.EventDate), + }, nil + +} + +func (s *Server) ReactivateApplication(ctx context.Context, req *app.ReactivateApplicationRequest) (*app.ReactivateApplicationResponse, error) { + details, err := s.command.ReactivateApplication(ctx, req.GetProjectId(), req.GetId(), "") + if err != nil { + return nil, err + } + + return &app.ReactivateApplicationResponse{ + ReactivationDate: timestamppb.New(details.EventDate), + }, nil + +} + +func (s *Server) RegenerateClientSecret(ctx context.Context, req *app.RegenerateClientSecretRequest) (*app.RegenerateClientSecretResponse, error) { + var secret string + var changeDate time.Time + + switch req.GetAppType().(type) { + case *app.RegenerateClientSecretRequest_IsApi: + config, err := s.command.ChangeAPIApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + if err != nil { + return nil, err + } + secret = config.ClientSecretString + changeDate = config.ChangeDate + + case *app.RegenerateClientSecretRequest_IsOidc: + config, err := s.command.ChangeOIDCApplicationSecret(ctx, req.GetProjectId(), req.GetApplicationId(), "") + if err != nil { + return nil, err + } + + secret = config.ClientSecretString + changeDate = config.ChangeDate + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "APP-aLWIzw", "unknown app type") + } + + return &app.RegenerateClientSecretResponse{ + ClientSecret: secret, + CreationDate: timestamppb.New(changeDate), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app.go b/internal/api/grpc/app/v2beta/convert/api_app.go new file mode 100644 index 0000000000..bad76ab0d5 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app.go @@ -0,0 +1,60 @@ +package convert + +import ( + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateAPIApplicationRequestToDomain(name, projectID, appID string, app *app.CreateAPIApplicationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func UpdateAPIApplicationConfigurationRequestToDomain(appID, projectID string, app *app.UpdateAPIApplicationConfigurationRequest) *domain.APIApp { + return &domain.APIApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + AuthMethodType: apiAuthMethodTypeToDomain(app.GetAuthMethodType()), + } +} + +func appAPIConfigToPb(apiApp *query.APIApp) app.ApplicationConfig { + return &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{ + ClientId: apiApp.ClientID, + AuthMethodType: apiAuthMethodTypeToPb(apiApp.AuthMethodType), + }, + } +} + +func apiAuthMethodTypeToDomain(authType app.APIAuthMethodType) domain.APIAuthMethodType { + switch authType { + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC: + return domain.APIAuthMethodTypeBasic + case app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.APIAuthMethodTypePrivateKeyJWT + default: + return domain.APIAuthMethodTypeBasic + } +} + +func apiAuthMethodTypeToPb(methodType domain.APIAuthMethodType) app.APIAuthMethodType { + switch methodType { + case domain.APIAuthMethodTypeBasic: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + case domain.APIAuthMethodTypePrivateKeyJWT: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC + } +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app_test.go b/internal/api/grpc/app/v2beta/convert/api_app_test.go new file mode 100644 index 0000000000..9f15c3df76 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/api_app_test.go @@ -0,0 +1,149 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateAPIApplicationRequestToDomain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + appName string + projectID string + appID string + req *app.CreateAPIApplicationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appName: "my-app", + projectID: "proj-1", + appID: "someID", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "my-app", + AuthMethodType: domain.APIAuthMethodTypeBasic, + AppID: "someID", + }, + }, + { + name: "private key jwt", + appName: "jwt-app", + projectID: "proj-2", + req: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppName: "jwt-app", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := CreateAPIApplicationRequestToDomain(tt.appName, tt.projectID, tt.appID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func TestUpdateAPIApplicationConfigurationRequestToDomain(t *testing.T) { + t.Parallel() + tests := []struct { + name string + appID string + projectID string + req *app.UpdateAPIApplicationConfigurationRequest + want *domain.APIApp + }{ + { + name: "basic auth method", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + AuthMethodType: domain.APIAuthMethodTypeBasic, + }, + }, + { + name: "private key jwt", + appID: "app-2", + projectID: "proj-2", + req: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + want: &domain.APIApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-2"}, + AppID: "app-2", + AuthMethodType: domain.APIAuthMethodTypePrivateKeyJWT, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + got := UpdateAPIApplicationConfigurationRequestToDomain(tt.appID, tt.projectID, tt.req) + + // Then + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_apiAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + methodType domain.APIAuthMethodType + expectedResult app.APIAuthMethodType + }{ + { + name: "basic auth method", + methodType: domain.APIAuthMethodTypeBasic, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "private key jwt", + methodType: domain.APIAuthMethodTypePrivateKeyJWT, + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth method defaults to basic", + expectedResult: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := apiAuthMethodTypeToPb(tc.methodType) + + // Then + assert.Equal(t, tc.expectedResult, res) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert.go b/internal/api/grpc/app/v2beta/convert/convert.go new file mode 100644 index 0000000000..c732b3a0c5 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert.go @@ -0,0 +1,165 @@ +package convert + +import ( + "net/url" + + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func AppToPb(query_app *query.App) *app.Application { + if query_app == nil { + return &app.Application{} + } + + return &app.Application{ + Id: query_app.ID, + CreationDate: timestamppb.New(query_app.CreationDate), + ChangeDate: timestamppb.New(query_app.ChangeDate), + State: appStateToPb(query_app.State), + Name: query_app.Name, + Config: appConfigToPb(query_app), + } +} + +func AppsToPb(queryApps []*query.App) []*app.Application { + pbApps := make([]*app.Application, len(queryApps)) + + for i, queryApp := range queryApps { + pbApps[i] = AppToPb(queryApp) + } + + return pbApps +} + +func ListApplicationsRequestToModel(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationsRequest) (*query.AppSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + queries, err := appQueriesToModel(req.GetFilters()) + if err != nil { + return nil, err + } + projectQuery, err := query.NewAppProjectIDSearchQuery(req.GetProjectId()) + if err != nil { + return nil, err + } + + queries = append(queries, projectQuery) + return &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: appSortingToColumn(req.GetSortingColumn()), + }, + + Queries: queries, + }, nil +} + +func appSortingToColumn(sortingCriteria app.AppSorting) query.Column { + switch sortingCriteria { + case app.AppSorting_APP_SORT_BY_CHANGE_DATE: + return query.AppColumnChangeDate + case app.AppSorting_APP_SORT_BY_CREATION_DATE: + return query.AppColumnCreationDate + case app.AppSorting_APP_SORT_BY_NAME: + return query.AppColumnName + case app.AppSorting_APP_SORT_BY_STATE: + return query.AppColumnState + case app.AppSorting_APP_SORT_BY_ID: + fallthrough + default: + return query.AppColumnID + } +} + +func appStateToPb(state domain.AppState) app.AppState { + switch state { + case domain.AppStateActive: + return app.AppState_APP_STATE_ACTIVE + case domain.AppStateInactive: + return app.AppState_APP_STATE_INACTIVE + case domain.AppStateRemoved: + return app.AppState_APP_STATE_REMOVED + case domain.AppStateUnspecified: + fallthrough + default: + return app.AppState_APP_STATE_UNSPECIFIED + } +} + +func appConfigToPb(app *query.App) app.ApplicationConfig { + if app.OIDCConfig != nil { + return appOIDCConfigToPb(app.OIDCConfig) + } + if app.SAMLConfig != nil { + return appSAMLConfigToPb(app.SAMLConfig) + } + return appAPIConfigToPb(app.APIConfig) +} + +func loginVersionToDomain(version *app.LoginVersion) (*domain.LoginVersion, *string, error) { + switch v := version.GetVersion().(type) { + case nil: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + case *app.LoginVersion_LoginV1: + return gu.Ptr(domain.LoginVersion1), gu.Ptr(""), nil + case *app.LoginVersion_LoginV2: + _, err := url.Parse(v.LoginV2.GetBaseUri()) + return gu.Ptr(domain.LoginVersion2), gu.Ptr(v.LoginV2.GetBaseUri()), err + default: + return gu.Ptr(domain.LoginVersionUnspecified), gu.Ptr(""), nil + } +} + +func loginVersionToPb(version domain.LoginVersion, baseURI *string) *app.LoginVersion { + switch version { + case domain.LoginVersionUnspecified: + return nil + case domain.LoginVersion1: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}} + case domain.LoginVersion2: + return &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: baseURI}}} + default: + return nil + } +} + +func appQueriesToModel(queries []*app.ApplicationSearchFilter) (toReturn []query.SearchQuery, err error) { + toReturn = make([]query.SearchQuery, len(queries)) + for i, query := range queries { + toReturn[i], err = appQueryToModel(query) + if err != nil { + return nil, err + } + } + return toReturn, nil +} + +func appQueryToModel(appQuery *app.ApplicationSearchFilter) (query.SearchQuery, error) { + switch q := appQuery.GetFilter().(type) { + case *app.ApplicationSearchFilter_NameFilter: + return query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(q.NameFilter.GetMethod()), q.NameFilter.Name) + case *app.ApplicationSearchFilter_StateFilter: + return query.NewAppStateSearchQuery(domain.AppState(q.StateFilter)) + case *app.ApplicationSearchFilter_ApiAppOnly: + return query.NewNotNullQuery(query.AppAPIConfigColumnAppID) + case *app.ApplicationSearchFilter_OidcAppOnly: + return query.NewNotNullQuery(query.AppOIDCConfigColumnAppID) + case *app.ApplicationSearchFilter_SamlAppOnly: + return query.NewNotNullQuery(query.AppSAMLConfigColumnAppID) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid") + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert_test.go b/internal/api/grpc/app/v2beta/convert/convert_test.go new file mode 100644 index 0000000000..5835691d43 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/convert_test.go @@ -0,0 +1,520 @@ +package convert + +import ( + "errors" + "fmt" + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + filter_pb_v2 "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + filter_pb_v2_beta "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" +) + +func TestAppToPb(t *testing.T) { + t.Parallel() + + now := time.Now() + + tt := []struct { + testName string + inputQueryApp *query.App + expectedPbApp *app.Application + }{ + { + testName: "full app conversion", + inputQueryApp: &query.App{ + ID: "id", + CreationDate: now, + ChangeDate: now, + State: domain.AppStateActive, + Name: "test-app", + APIConfig: &query.APIApp{}, + }, + expectedPbApp: &app.Application{ + Id: "id", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + State: app.AppState_APP_STATE_ACTIVE, + Name: "test-app", + Config: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + }, + { + testName: "nil app", + inputQueryApp: nil, + expectedPbApp: &app.Application{}, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := AppToPb(tc.inputQueryApp) + + // Then + assert.Equal(t, tc.expectedPbApp, res) + }) + } +} + +func TestListApplicationsRequestToModel(t *testing.T) { + t.Parallel() + + validSearchByNameQuery, err := query.NewAppNameSearchQuery(filter.TextMethodPbToQuery(filter_pb_v2_beta.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS), "test") + require.NoError(t, err) + + validSearchByProjectQuery, err := query.NewAppProjectIDSearchQuery("project1") + require.NoError(t, err) + + sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150} + + tt := []struct { + testName string + req *app.ListApplicationsRequest + + expectedResponse *query.AppSearchQueries + expectedError error + }{ + { + testName: "invalid pagination limit", + req: &app.ListApplicationsRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)}, + }, + expectedResponse: nil, + expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + testName: "empty request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnID, + }, + Queries: []query.SearchQuery{ + validSearchByProjectQuery, + }, + }, + }, + { + testName: "valid request", + req: &app.ListApplicationsRequest{ + ProjectId: "project1", + Filters: []*app.ApplicationSearchFilter{ + { + Filter: &app.ApplicationSearchFilter_NameFilter{NameFilter: &app.ApplicationNameQuery{Name: "test"}}, + }, + }, + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + + expectedResponse: &query.AppSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AppColumnName, + }, + Queries: []query.SearchQuery{ + validSearchByNameQuery, + validSearchByProjectQuery, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := ListApplicationsRequestToModel(sysDefaults, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, got) + }) + } +} + +func TestAppSortingToColumn(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + sorting app.AppSorting + expected query.Column + }{ + { + name: "sort by change date", + sorting: app.AppSorting_APP_SORT_BY_CHANGE_DATE, + expected: query.AppColumnChangeDate, + }, + { + name: "sort by creation date", + sorting: app.AppSorting_APP_SORT_BY_CREATION_DATE, + expected: query.AppColumnCreationDate, + }, + { + name: "sort by name", + sorting: app.AppSorting_APP_SORT_BY_NAME, + expected: query.AppColumnName, + }, + { + name: "sort by state", + sorting: app.AppSorting_APP_SORT_BY_STATE, + expected: query.AppColumnState, + }, + { + name: "sort by ID", + sorting: app.AppSorting_APP_SORT_BY_ID, + expected: query.AppColumnID, + }, + { + name: "unknown sorting defaults to ID", + sorting: app.AppSorting(99), + expected: query.AppColumnID, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appSortingToColumn(tc.sorting) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppStateToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + state domain.AppState + expected app.AppState + }{ + { + name: "active state", + state: domain.AppStateActive, + expected: app.AppState_APP_STATE_ACTIVE, + }, + { + name: "inactive state", + state: domain.AppStateInactive, + expected: app.AppState_APP_STATE_INACTIVE, + }, + { + name: "removed state", + state: domain.AppStateRemoved, + expected: app.AppState_APP_STATE_REMOVED, + }, + { + name: "unspecified state", + state: domain.AppStateUnspecified, + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + { + name: "unknown state defaults to unspecified", + state: domain.AppState(99), + expected: app.AppState_APP_STATE_UNSPECIFIED, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appStateToPb(tc.state) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + app *query.App + expected app.ApplicationConfig + }{ + { + name: "OIDC config", + app: &query.App{ + OIDCConfig: &query.OIDCApp{}, + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: &durationpb.Duration{}, + }, + }, + }, + { + name: "SAML config", + app: &query.App{ + SAMLConfig: &query.SAMLApp{}, + }, + expected: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + }, + }, + }, + { + name: "API config", + app: &query.App{ + APIConfig: &query.APIApp{}, + }, + expected: &app.Application_ApiConfig{ + ApiConfig: &app.APIConfig{}, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := appConfigToPb(tc.app) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestLoginVersionToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version *app.LoginVersion + expectedVer *domain.LoginVersion + expectedURI *string + expectedError error + }{ + { + name: "nil version", + version: nil, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + { + name: "login v1", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + expectedVer: gu.Ptr(domain.LoginVersion1), + expectedURI: gu.Ptr(""), + }, + { + name: "login v2 valid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://valid.url")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("https://valid.url"), + }, + { + name: "login v2 invalid URI", + version: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: gu.Ptr("://invalid")}}}, + expectedVer: gu.Ptr(domain.LoginVersion2), + expectedURI: gu.Ptr("://invalid"), + expectedError: &url.Error{Op: "parse", URL: "://invalid", Err: errors.New("missing protocol scheme")}, + }, + { + name: "unknown version type", + version: &app.LoginVersion{}, + expectedVer: gu.Ptr(domain.LoginVersionUnspecified), + expectedURI: gu.Ptr(""), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + version, uri, err := loginVersionToDomain(tc.version) + + // Then + assert.Equal(t, tc.expectedVer, version) + assert.Equal(t, tc.expectedURI, uri) + assert.Equal(t, tc.expectedError, err) + }) + } +} + +func TestLoginVersionToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + version domain.LoginVersion + baseURI *string + expected *app.LoginVersion + }{ + { + name: "unspecified version", + version: domain.LoginVersionUnspecified, + baseURI: nil, + expected: nil, + }, + { + name: "login v1", + version: domain.LoginVersion1, + baseURI: nil, + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV1{ + LoginV1: &app.LoginV1{}, + }, + }, + }, + { + name: "login v2", + version: domain.LoginVersion2, + baseURI: gu.Ptr("https://example.com"), + expected: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://example.com"), + }, + }, + }, + }, + { + name: "unknown version", + version: domain.LoginVersion(99), + baseURI: nil, + expected: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := loginVersionToPb(tc.version, tc.baseURI) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestAppQueryToModel(t *testing.T) { + t.Parallel() + + validAppNameSearchQuery, err := query.NewAppNameSearchQuery(query.TextEquals, "test") + require.NoError(t, err) + + validAppStateSearchQuery, err := query.NewAppStateSearchQuery(domain.AppStateActive) + require.NoError(t, err) + + tt := []struct { + name string + query *app.ApplicationSearchFilter + + expectedQuery query.SearchQuery + expectedError error + }{ + { + name: "name query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_NameFilter{ + NameFilter: &app.ApplicationNameQuery{ + Name: "test", + Method: filter_pb_v2.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + }, + }, + expectedQuery: validAppNameSearchQuery, + }, + { + name: "state query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_StateFilter{ + StateFilter: app.AppState_APP_STATE_ACTIVE, + }, + }, + expectedQuery: validAppStateSearchQuery, + }, + { + name: "api app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_ApiAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppAPIConfigColumnAppID, + }, + }, + { + name: "oidc app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_OidcAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppOIDCConfigColumnAppID, + }, + }, + { + name: "saml app only query", + query: &app.ApplicationSearchFilter{ + Filter: &app.ApplicationSearchFilter_SamlAppOnly{}, + }, + expectedQuery: &query.NotNullQuery{ + Column: query.AppSAMLConfigColumnAppID, + }, + }, + { + name: "invalid query type", + query: &app.ApplicationSearchFilter{}, + expectedQuery: nil, + expectedError: zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result, err := appQueryToModel(tc.query) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedQuery, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app.go b/internal/api/grpc/app/v2beta/convert/oidc_app.go new file mode 100644 index 0000000000..223e43d166 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app.go @@ -0,0 +1,291 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateOIDCAppRequestToDomain(name, projectID string, req *app.CreateOIDCApplicationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: req.GetRedirectUris(), + ResponseTypes: oidcResponseTypesToDomain(req.GetResponseTypes()), + GrantTypes: oidcGrantTypesToDomain(req.GetGrantTypes()), + ApplicationType: gu.Ptr(oidcApplicationTypeToDomain(req.GetAppType())), + AuthMethodType: gu.Ptr(oidcAuthMethodTypeToDomain(req.GetAuthMethodType())), + PostLogoutRedirectUris: req.GetPostLogoutRedirectUris(), + DevMode: &req.DevMode, + AccessTokenType: gu.Ptr(oidcTokenTypeToDomain(req.GetAccessTokenType())), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), + AdditionalOrigins: req.GetAdditionalOrigins(), + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateOIDCAppConfigRequestToDomain(appID, projectID string, app *app.UpdateOIDCApplicationConfigurationRequest) (*domain.OIDCApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + RedirectUris: app.RedirectUris, + ResponseTypes: oidcResponseTypesToDomain(app.ResponseTypes), + GrantTypes: oidcGrantTypesToDomain(app.GrantTypes), + ApplicationType: oidcApplicationTypeToDomainPtr(app.AppType), + AuthMethodType: oidcAuthMethodTypeToDomainPtr(app.AuthMethodType), + PostLogoutRedirectUris: app.PostLogoutRedirectUris, + DevMode: app.DevMode, + AccessTokenType: oidcTokenTypeToDomainPtr(app.AccessTokenType), + AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, + IDTokenRoleAssertion: app.IdTokenRoleAssertion, + IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), + AdditionalOrigins: app.AdditionalOrigins, + SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, + BackChannelLogoutURI: app.BackChannelLogoutUri, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func oidcResponseTypesToDomain(responseTypes []app.OIDCResponseType) []domain.OIDCResponseType { + if len(responseTypes) == 0 { + return []domain.OIDCResponseType{domain.OIDCResponseTypeCode} + } + oidcResponseTypes := make([]domain.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED: + oidcResponseTypes[i] = domain.OIDCResponseTypeUnspecified + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE: + oidcResponseTypes[i] = domain.OIDCResponseTypeCode + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDToken + case app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN: + oidcResponseTypes[i] = domain.OIDCResponseTypeIDTokenToken + } + } + return oidcResponseTypes +} + +func oidcGrantTypesToDomain(grantTypes []app.OIDCGrantType) []domain.OIDCGrantType { + if len(grantTypes) == 0 { + return []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode} + } + oidcGrantTypes := make([]domain.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeAuthorizationCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT: + oidcGrantTypes[i] = domain.OIDCGrantTypeImplicit + case app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN: + oidcGrantTypes[i] = domain.OIDCGrantTypeRefreshToken + case app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE: + oidcGrantTypes[i] = domain.OIDCGrantTypeDeviceCode + case app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE: + oidcGrantTypes[i] = domain.OIDCGrantTypeTokenExchange + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToDomainPtr(appType *app.OIDCAppType) *domain.OIDCApplicationType { + if appType == nil { + return nil + } + + res := oidcApplicationTypeToDomain(*appType) + return &res +} + +func oidcApplicationTypeToDomain(appType app.OIDCAppType) domain.OIDCApplicationType { + switch appType { + case app.OIDCAppType_OIDC_APP_TYPE_WEB: + return domain.OIDCApplicationTypeWeb + case app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT: + return domain.OIDCApplicationTypeUserAgent + case app.OIDCAppType_OIDC_APP_TYPE_NATIVE: + return domain.OIDCApplicationTypeNative + } + return domain.OIDCApplicationTypeWeb +} + +func oidcAuthMethodTypeToDomainPtr(authType *app.OIDCAuthMethodType) *domain.OIDCAuthMethodType { + if authType == nil { + return nil + } + + res := oidcAuthMethodTypeToDomain(*authType) + return &res +} + +func oidcAuthMethodTypeToDomain(authType app.OIDCAuthMethodType) domain.OIDCAuthMethodType { + switch authType { + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC: + return domain.OIDCAuthMethodTypeBasic + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST: + return domain.OIDCAuthMethodTypePost + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE: + return domain.OIDCAuthMethodTypeNone + case app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT: + return domain.OIDCAuthMethodTypePrivateKeyJWT + default: + return domain.OIDCAuthMethodTypeBasic + } +} + +func oidcTokenTypeToDomainPtr(tokenType *app.OIDCTokenType) *domain.OIDCTokenType { + if tokenType == nil { + return nil + } + + res := oidcTokenTypeToDomain(*tokenType) + return &res +} + +func oidcTokenTypeToDomain(tokenType app.OIDCTokenType) domain.OIDCTokenType { + switch tokenType { + case app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER: + return domain.OIDCTokenTypeBearer + case app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT: + return domain.OIDCTokenTypeJWT + default: + return domain.OIDCTokenTypeBearer + } +} + +func ComplianceProblemsToLocalizedMessages(complianceProblems []string) []*app.OIDCLocalizedMessage { + converted := make([]*app.OIDCLocalizedMessage, len(complianceProblems)) + for i, p := range complianceProblems { + converted[i] = &app.OIDCLocalizedMessage{Key: p} + } + + return converted +} + +func appOIDCConfigToPb(oidcApp *query.OIDCApp) *app.Application_OidcConfig { + return &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: oidcApp.RedirectURIs, + ResponseTypes: oidcResponseTypesFromModel(oidcApp.ResponseTypes), + GrantTypes: oidcGrantTypesFromModel(oidcApp.GrantTypes), + AppType: oidcApplicationTypeToPb(oidcApp.AppType), + ClientId: oidcApp.ClientID, + AuthMethodType: oidcAuthMethodTypeToPb(oidcApp.AuthMethodType), + PostLogoutRedirectUris: oidcApp.PostLogoutRedirectURIs, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: len(oidcApp.ComplianceProblems) != 0, + ComplianceProblems: ComplianceProblemsToLocalizedMessages(oidcApp.ComplianceProblems), + DevMode: oidcApp.IsDevMode, + AccessTokenType: oidcTokenTypeToPb(oidcApp.AccessTokenType), + AccessTokenRoleAssertion: oidcApp.AssertAccessTokenRole, + IdTokenRoleAssertion: oidcApp.AssertIDTokenRole, + IdTokenUserinfoAssertion: oidcApp.AssertIDTokenUserinfo, + ClockSkew: durationpb.New(oidcApp.ClockSkew), + AdditionalOrigins: oidcApp.AdditionalOrigins, + AllowedOrigins: oidcApp.AllowedOrigins, + SkipNativeAppSuccessPage: oidcApp.SkipNativeAppSuccessPage, + BackChannelLogoutUri: oidcApp.BackChannelLogoutURI, + LoginVersion: loginVersionToPb(oidcApp.LoginVersion, oidcApp.LoginBaseURI), + }, + } +} + +func oidcResponseTypesFromModel(responseTypes []domain.OIDCResponseType) []app.OIDCResponseType { + oidcResponseTypes := make([]app.OIDCResponseType, len(responseTypes)) + for i, responseType := range responseTypes { + switch responseType { + case domain.OIDCResponseTypeUnspecified: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED + case domain.OIDCResponseTypeCode: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE + case domain.OIDCResponseTypeIDToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN + case domain.OIDCResponseTypeIDTokenToken: + oidcResponseTypes[i] = app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN + } + } + return oidcResponseTypes +} + +func oidcGrantTypesFromModel(grantTypes []domain.OIDCGrantType) []app.OIDCGrantType { + oidcGrantTypes := make([]app.OIDCGrantType, len(grantTypes)) + for i, grantType := range grantTypes { + switch grantType { + case domain.OIDCGrantTypeAuthorizationCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE + case domain.OIDCGrantTypeImplicit: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT + case domain.OIDCGrantTypeRefreshToken: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN + case domain.OIDCGrantTypeDeviceCode: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE + case domain.OIDCGrantTypeTokenExchange: + oidcGrantTypes[i] = app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE + } + } + return oidcGrantTypes +} + +func oidcApplicationTypeToPb(appType domain.OIDCApplicationType) app.OIDCAppType { + switch appType { + case domain.OIDCApplicationTypeWeb: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + case domain.OIDCApplicationTypeUserAgent: + return app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT + case domain.OIDCApplicationTypeNative: + return app.OIDCAppType_OIDC_APP_TYPE_NATIVE + default: + return app.OIDCAppType_OIDC_APP_TYPE_WEB + } +} + +func oidcAuthMethodTypeToPb(authType domain.OIDCAuthMethodType) app.OIDCAuthMethodType { + switch authType { + case domain.OIDCAuthMethodTypeBasic: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + case domain.OIDCAuthMethodTypePost: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST + case domain.OIDCAuthMethodTypeNone: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE + case domain.OIDCAuthMethodTypePrivateKeyJWT: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT + default: + return app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC + } +} + +func oidcTokenTypeToPb(tokenType domain.OIDCTokenType) app.OIDCTokenType { + switch tokenType { + case domain.OIDCTokenTypeBearer: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + case domain.OIDCTokenTypeJWT: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT + default: + return app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER + } +} diff --git a/internal/api/grpc/app/v2beta/convert/oidc_app_test.go b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go new file mode 100644 index 0000000000..a6b3f0b709 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/oidc_app_test.go @@ -0,0 +1,755 @@ +package convert + +import ( + "net/url" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/durationpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateOIDCAppRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + projectID string + req *app.CreateOIDCApplicationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + projectID: "pid", + req: &app.CreateOIDCApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}}, + }, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "all fields set", + projectID: "project1", + req: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://backchannel", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login"), + }}}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1"}, + AppName: "all fields set", + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateOIDCAppRequestToDomain(tc.testName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, res) + }) + } +} + +func TestUpdateOIDCAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + + appID string + projectID string + req *app.UpdateOIDCApplicationConfigurationRequest + + expectedModel *domain.OIDCApp + expectedError error + }{ + { + testName: "unparsable login version 2 URL", + appID: "app1", + projectID: "pid", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }}, + }, + expectedModel: nil, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "successful Update", + appID: "app1", + projectID: "proj1", + req: &app.UpdateOIDCApplicationConfigurationRequest{ + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: gu.Ptr(app.OIDCAppType_OIDC_APP_TYPE_WEB), + AuthMethodType: gu.Ptr(app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER), + AccessTokenRoleAssertion: gu.Ptr(true), + IdTokenRoleAssertion: gu.Ptr(true), + IdTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutUri: gu.Ptr("https://backchannel"), + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://login")}, + }}, + }, + expectedModel: &domain.OIDCApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj1"}, + AppID: "app1", + RedirectUris: []string{"https://redirect"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + PostLogoutRedirectUris: []string{"https://logout"}, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(5 * time.Second), + AdditionalOrigins: []string{"https://origin"}, + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login"), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := UpdateOIDCAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedModel, got) + }) + } +} + +func TestOIDCResponseTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputResponseType []app.OIDCResponseType + expectedResponse []domain.OIDCResponseType + }{ + { + testName: "empty response types", + inputResponseType: []app.OIDCResponseType{}, + expectedResponse: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + }, + { + testName: "all response types", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + }, + { + testName: "single response type", + inputResponseType: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + expectedResponse: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcResponseTypesToDomain(tc.inputResponseType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCGrantTypesToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputGrantType []app.OIDCGrantType + expectedGrants []domain.OIDCGrantType + }{ + { + testName: "empty grant types", + inputGrantType: []app.OIDCGrantType{}, + expectedGrants: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + }, + { + testName: "all grant types", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + }, + { + testName: "single grant type", + inputGrantType: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + expectedGrants: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res := oidcGrantTypesToDomain(tc.inputGrantType) + + // Then + assert.Equal(t, tc.expectedGrants, res) + }) + } +} + +func TestOIDCApplicationTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType app.OIDCAppType + expected domain.OIDCApplicationType + }{ + { + name: "web type", + appType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + expected: domain.OIDCApplicationTypeWeb, + }, + { + name: "user agent type", + appType: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + expected: domain.OIDCApplicationTypeUserAgent, + }, + { + name: "native type", + appType: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + expected: domain.OIDCApplicationTypeNative, + }, + { + name: "unspecified type defaults to web", + expected: domain.OIDCApplicationTypeWeb, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToDomain(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType app.OIDCAuthMethodType + expectedResponse domain.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + { + name: "post auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + expectedResponse: domain.OIDCAuthMethodTypePost, + }, + { + name: "none auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + expectedResponse: domain.OIDCAuthMethodTypeNone, + }, + { + name: "private key jwt auth type", + authType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + expectedResponse: domain.OIDCAuthMethodTypePrivateKeyJWT, + }, + { + name: "unspecified auth type defaults to basic", + expectedResponse: domain.OIDCAuthMethodTypeBasic, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + res := oidcAuthMethodTypeToDomain(tc.authType) + + // Then + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestOIDCTokenTypeToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType app.OIDCTokenType + expectedType domain.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + expectedType: domain.OIDCTokenTypeBearer, + }, + { + name: "jwt token type", + tokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + expectedType: domain.OIDCTokenTypeJWT, + }, + { + name: "unspecified defaults to bearer", + expectedType: domain.OIDCTokenTypeBearer, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToDomain(tc.tokenType) + + // Then + assert.Equal(t, tc.expectedType, result) + }) + } +} +func TestAppOIDCConfigToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + input *query.OIDCApp + expected *app.Application_OidcConfig + }{ + { + name: "empty config", + input: &query.OIDCApp{}, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + Version: app.OIDCVersion_OIDC_VERSION_1_0, + ComplianceProblems: []*app.OIDCLocalizedMessage{}, + ClockSkew: durationpb.New(0), + ResponseTypes: []app.OIDCResponseType{}, + GrantTypes: []app.OIDCGrantType{}, + }, + }, + }, + { + name: "full config", + input: &query.OIDCApp{ + RedirectURIs: []string{"https://example.com/callback"}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + AppType: domain.OIDCApplicationTypeWeb, + ClientID: "client123", + AuthMethodType: domain.OIDCAuthMethodTypeBasic, + PostLogoutRedirectURIs: []string{"https://example.com/logout"}, + ComplianceProblems: []string{"problem1", "problem2"}, + IsDevMode: true, + AccessTokenType: domain.OIDCTokenTypeBearer, + AssertAccessTokenRole: true, + AssertIDTokenRole: true, + AssertIDTokenUserinfo: true, + ClockSkew: 5 * time.Second, + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutURI: "https://example.com/backchannel", + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://login.example.com"), + }, + expected: &app.Application_OidcConfig{ + OidcConfig: &app.OIDCConfig{ + RedirectUris: []string{"https://example.com/callback"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + ClientId: "client123", + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"https://example.com/logout"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + NoneCompliant: true, + ComplianceProblems: []*app.OIDCLocalizedMessage{ + {Key: "problem1"}, + {Key: "problem2"}, + }, + DevMode: true, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: durationpb.New(5 * time.Second), + AdditionalOrigins: []string{"https://app.example.com"}, + AllowedOrigins: []string{"https://allowed.example.com"}, + SkipNativeAppSuccessPage: true, + BackChannelLogoutUri: "https://example.com/backchannel", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: gu.Ptr("https://login.example.com"), + }, + }, + }, + }, + }, + }, + } + + for _, tt := range tt { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // When + result := appOIDCConfigToPb(tt.input) + + // Then + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestOIDCResponseTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + responseTypes []domain.OIDCResponseType + expected []app.OIDCResponseType + }{ + { + name: "empty response types", + responseTypes: []domain.OIDCResponseType{}, + expected: []app.OIDCResponseType{}, + }, + { + name: "all response types", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeUnspecified, + domain.OIDCResponseTypeCode, + domain.OIDCResponseTypeIDToken, + domain.OIDCResponseTypeIDTokenToken, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_UNSPECIFIED, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN, + app.OIDCResponseType_OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN, + }, + }, + { + name: "single response type", + responseTypes: []domain.OIDCResponseType{ + domain.OIDCResponseTypeCode, + }, + expected: []app.OIDCResponseType{ + app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcResponseTypesFromModel(tc.responseTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} +func TestOIDCGrantTypesFromModel(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + grantTypes []domain.OIDCGrantType + expected []app.OIDCGrantType + }{ + { + name: "empty grant types", + grantTypes: []domain.OIDCGrantType{}, + expected: []app.OIDCGrantType{}, + }, + { + name: "all grant types", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + domain.OIDCGrantTypeImplicit, + domain.OIDCGrantTypeRefreshToken, + domain.OIDCGrantTypeDeviceCode, + domain.OIDCGrantTypeTokenExchange, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_IMPLICIT, + app.OIDCGrantType_OIDC_GRANT_TYPE_REFRESH_TOKEN, + app.OIDCGrantType_OIDC_GRANT_TYPE_DEVICE_CODE, + app.OIDCGrantType_OIDC_GRANT_TYPE_TOKEN_EXCHANGE, + }, + }, + { + name: "single grant type", + grantTypes: []domain.OIDCGrantType{ + domain.OIDCGrantTypeAuthorizationCode, + }, + expected: []app.OIDCGrantType{ + app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcGrantTypesFromModel(tc.grantTypes) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCApplicationTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + appType domain.OIDCApplicationType + expected app.OIDCAppType + }{ + { + name: "web type", + appType: domain.OIDCApplicationTypeWeb, + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + { + name: "user agent type", + appType: domain.OIDCApplicationTypeUserAgent, + expected: app.OIDCAppType_OIDC_APP_TYPE_USER_AGENT, + }, + { + name: "native type", + appType: domain.OIDCApplicationTypeNative, + expected: app.OIDCAppType_OIDC_APP_TYPE_NATIVE, + }, + { + name: "unspecified type defaults to web", + appType: domain.OIDCApplicationType(999), // Invalid value to trigger default case + expected: app.OIDCAppType_OIDC_APP_TYPE_WEB, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcApplicationTypeToPb(tc.appType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCAuthMethodTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + authType domain.OIDCAuthMethodType + expected app.OIDCAuthMethodType + }{ + { + name: "basic auth type", + authType: domain.OIDCAuthMethodTypeBasic, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + { + name: "post auth type", + authType: domain.OIDCAuthMethodTypePost, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_POST, + }, + { + name: "none auth type", + authType: domain.OIDCAuthMethodTypeNone, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_NONE, + }, + { + name: "private key jwt auth type", + authType: domain.OIDCAuthMethodTypePrivateKeyJWT, + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + { + name: "unknown auth type defaults to basic", + authType: domain.OIDCAuthMethodType(999), + expected: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcAuthMethodTypeToPb(tc.authType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestOIDCTokenTypeToPb(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + tokenType domain.OIDCTokenType + expected app.OIDCTokenType + }{ + { + name: "bearer token type", + tokenType: domain.OIDCTokenTypeBearer, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + { + name: "jwt token type", + tokenType: domain.OIDCTokenTypeJWT, + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + }, + { + name: "unknown token type defaults to bearer", + tokenType: domain.OIDCTokenType(999), // Invalid value to trigger default case + expected: app.OIDCTokenType_OIDC_TOKEN_TYPE_BEARER, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + result := oidcTokenTypeToPb(tc.tokenType) + + // Then + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app.go b/internal/api/grpc/app/v2beta/convert/saml_app.go new file mode 100644 index 0000000000..7f1bef082b --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app.go @@ -0,0 +1,77 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func CreateSAMLAppRequestToDomain(name, projectID string, req *app.CreateSAMLApplicationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(req.GetLoginVersion()) + if err != nil { + return nil, err + } + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppName: name, + Metadata: req.GetMetadataXml(), + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func UpdateSAMLAppConfigRequestToDomain(appID, projectID string, app *app.UpdateSAMLApplicationConfigurationRequest) (*domain.SAMLApp, error) { + loginVersion, loginBaseURI, err := loginVersionToDomain(app.GetLoginVersion()) + if err != nil { + return nil, err + } + + metasXML, metasURL := metasToDomain(app.GetMetadata()) + return &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: projectID, + }, + AppID: appID, + Metadata: metasXML, + MetadataURL: metasURL, + LoginVersion: loginVersion, + LoginBaseURI: loginBaseURI, + }, nil +} + +func metasToDomain(metas app.MetaType) ([]byte, *string) { + switch t := metas.(type) { + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataXml: + return t.MetadataXml, nil + case *app.UpdateSAMLApplicationConfigurationRequest_MetadataUrl: + return nil, &t.MetadataUrl + case nil: + return nil, nil + default: + return nil, nil + } +} + +func appSAMLConfigToPb(samlApp *query.SAMLApp) app.ApplicationConfig { + if samlApp == nil { + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + } + } + + return &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{MetadataXml: samlApp.Metadata}, + LoginVersion: loginVersionToPb(samlApp.LoginVersion, samlApp.LoginBaseURI), + }, + } +} diff --git a/internal/api/grpc/app/v2beta/convert/saml_app_test.go b/internal/api/grpc/app/v2beta/convert/saml_app_test.go new file mode 100644 index 0000000000..b41ec432b6 --- /dev/null +++ b/internal/api/grpc/app/v2beta/convert/saml_app_test.go @@ -0,0 +1,256 @@ +package convert + +import ( + "fmt" + "net/url" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func TestCreateSAMLAppRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appName string + projectID string + req *app.CreateSAMLApplicationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appName: "test-app", + projectID: "proj-1", + req: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppName: "test-app", + Metadata: genMetaForValidRequest, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + State: 0, + }, + }, + { + testName: "nil request", + appName: "test-app", + projectID: "proj-1", + req: nil, + + expectedResponse: &domain.SAMLApp{ + AppName: "test-app", + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := CreateSAMLAppRequestToDomain(tc.appName, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} +func TestUpdateSAMLAppConfigRequestToDomain(t *testing.T) { + t.Parallel() + + genMetaForValidRequest := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + testName string + appID string + projectID string + req *app.UpdateSAMLApplicationConfigurationRequest + + expectedResponse *domain.SAMLApp + expectedError error + }{ + { + testName: "login version error", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("%+o")}, + }, + }, + }, + expectedError: &url.Error{ + URL: "%+o", + Op: "parse", + Err: url.EscapeError("%+o"), + }, + }, + { + testName: "valid request", + appID: "app-1", + projectID: "proj-1", + req: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: genMetaForValidRequest, + }, + LoginVersion: nil, + }, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + Metadata: genMetaForValidRequest, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + { + testName: "nil request", + appID: "app-1", + projectID: "proj-1", + req: nil, + expectedResponse: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{AggregateID: "proj-1"}, + AppID: "app-1", + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := UpdateSAMLAppConfigRequestToDomain(tc.appID, tc.projectID, tc.req) + + // Then + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResponse, res) + }) + } +} + +func TestAppSAMLConfigToPb(t *testing.T) { + t.Parallel() + + metadata := samlMetadataGen(gofakeit.URL()) + + tt := []struct { + name string + inputSAMLApp *query.SAMLApp + + expectedPbApp app.ApplicationConfig + }{ + { + name: "valid conversion", + inputSAMLApp: &query.SAMLApp{ + Metadata: metadata, + LoginVersion: domain.LoginVersion2, + LoginBaseURI: gu.Ptr("https://example.com"), + }, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{ + MetadataXml: metadata, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{BaseUri: gu.Ptr("https://example.com")}, + }, + }, + }, + }, + }, + { + name: "nil saml app", + inputSAMLApp: nil, + expectedPbApp: &app.Application_SamlConfig{ + SamlConfig: &app.SAMLConfig{ + Metadata: &app.SAMLConfig_MetadataXml{}, + LoginVersion: &app.LoginVersion{}, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // When + got := appSAMLConfigToPb(tc.inputSAMLApp) + + // Then + assert.Equal(t, tc.expectedPbApp, got) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_test.go b/internal/api/grpc/app/v2beta/integration_test/app_test.go new file mode 100644 index 0000000000..1ba46987cf --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/app_test.go @@ -0,0 +1,1446 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "testing" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" +) + +func TestCreateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + t.Parallel() + + notExistingProjectID := gofakeit.UUID() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + { + testName: "when project for API app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateAPIApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when project for OIDC app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: "App Name", + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateOIDCApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when project for SAML app creation is not found should return failed precondition error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: notExistingProjectID, + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataUrl{ + MetadataUrl: "http://example.com/metas", + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateSAMLApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestCreateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationRequest + inputCtx context.Context + + expectedResponseType string + expectedErrorType codes.Code + }{ + // Login User with no project.app.write + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // OrgOwner with project.app.write permission + { + testName: "when user is OrgOwner API request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is OrgOwner OIDC request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + + // Project owner with project.app.write permission + { + testName: "when user is ProjectOwner API request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.Name(), + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_ApiResponse{}), + }, + { + testName: "when user is ProjectOwner OIDC request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_OidcResponse{}), + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetadataGen(gofakeit.URL()), + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }, + expectedResponseType: fmt.Sprintf("%T", &app.CreateApplicationResponse_SamlResponse{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplication(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + resType := fmt.Sprintf("%T", res.GetCreationResponseType()) + assert.Equal(t, tc.expectedResponseType, resType) + assert.NotZero(t, res.GetAppId()) + assert.NotZero(t, res.GetCreationDate()) + } + }) + } +} + +func TestUpdateApplication(t *testing.T) { + orgNotInCtx := instance.CreateOrganization(IAMOwnerCtx, gofakeit.Name(), gofakeit.Email()) + pNotInCtx := instance.CreateProject(IAMOwnerCtx, t, orgNotInCtx.GetOrganizationId(), gofakeit.AppName(), false, false) + + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + baseURI := "http://example.com" + + t.Cleanup(func() { + instance.Client.OrgV2beta.DeleteOrganization(IAMOwnerCtx, &org.DeleteOrganizationRequest{ + Id: orgNotInCtx.GetOrganizationId(), + }) + }) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + reqForAPIAppCreation := reqForAppNameCreation + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + samlMetas := samlMetadataGen(gofakeit.URL()) + reqForSAMLAppCreation := &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, appOIDCConfigChangeErr) + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForSAMLAppCreation, + }) + require.Nil(t, appSAMLConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app for app name change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForNameChange.GetAppId(), + Name: "New name", + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for app name change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: "New name", + }, + }, + + { + testName: "when app for API config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for API config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when app for OIDC config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for OIDC config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + + { + testName: "when app for SAML config change request is not found should return not found error", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: pNotInCtx.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when request for SAML config change is valid should return updated timestamp", + inputCtx: IAMOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChange.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestUpdateApplication_WithDifferentPermissions(t *testing.T) { + baseURI := "http://example.com" + + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForNameChange, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + appForAPIConfigChangeForProjectOwner := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForOrgOwner := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForLoginUser := createAPIApp(t, p.GetId()) + + appForOIDCConfigChangeForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + appForOIDCConfigChangeForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + + samlMetasForProjectOwner, appForSAMLConfigChangeForProjectOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForOrgOwner, appForSAMLConfigChangeForOrgOwner := createSAMLApp(t, baseURI, p.GetId()) + samlMetasForLoginUser, appForSAMLConfigChangeForLoginUser := createSAMLApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + updateRequest *app.UpdateApplicationRequest + + expectedErrorType codes.Code + }{ + // ProjectOwner + { + testName: "when user is ProjectOwner app name request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is ProjectOwner API app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner OIDC app request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is ProjectOwner SAML request should succeed", + inputCtx: projectOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForProjectOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForProjectOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // OrgOwner context + { + testName: "when user is OrgOwner app name request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + }, + { + testName: "when user is OrgOwner API app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + }, + { + testName: "when user is OrgOwner OIDC app request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + }, + { + testName: "when user is OrgOwner SAML request should succeed", + inputCtx: OrgOwnerCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForOrgOwner.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForOrgOwner, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app name change request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForNameChange.GetAppId(), + + Name: gofakeit.AppName(), + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for API request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForAPIConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_ApiConfigurationRequest{ + ApiConfigurationRequest: &app.UpdateAPIApplicationConfigurationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForOIDCConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_OidcConfigurationRequest{ + OidcConfigurationRequest: &app.UpdateOIDCApplicationConfigurationRequest{ + PostLogoutRedirectUris: []string{"http://example.com/home2"}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for SAML request should return permission error", + inputCtx: LoginUserCtx, + updateRequest: &app.UpdateApplicationRequest{ + ProjectId: p.GetId(), + Id: appForSAMLConfigChangeForLoginUser.GetAppId(), + UpdateRequestType: &app.UpdateApplicationRequest_SamlConfigurationRequest{ + SamlConfigurationRequest: &app.UpdateSAMLApplicationConfigurationRequest{ + Metadata: &app.UpdateSAMLApplicationConfigurationRequest_MetadataXml{ + MetadataXml: samlMetasForLoginUser, + }, + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + }, + }, + }, + expectedErrorType: codes.PermissionDenied, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.UpdateApplication(tc.inputCtx, tc.updateRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetChangeDate()) + } + }) + } +} + +func TestDeleteApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDelete, appNameChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appNameChangeErr) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app to delete is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to delete is found should return deletion time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDelete.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeleteApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeleteForLoginUser := createAPIApp(t, p.GetId()) + appToDeleteForProjectOwner := createAPIApp(t, p.GetId()) + appToDeleteForOrgOwner := createAPIApp(t, p.GetId()) + + t.Parallel() + tt := []struct { + testName string + deleteRequest *app.DeleteApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.delete permission for app delete request should return permission error", + inputCtx: LoginUserCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner delete app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeleteApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeleteForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeleteApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeletionDate()) + } + }) + } +} + +func TestDeactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToDeactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.NoError(t, appCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to deactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to deactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestDeactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToDeactivateForLoginUser := createAPIApp(t, p.GetId()) + appToDeactivateForPrjectOwner := createAPIApp(t, p.GetId()) + appToDeactivateForOrgOwner := createAPIApp(t, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + deleteRequest *app.DeactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app deactivate request should return permission error", + inputCtx: IAMOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForLoginUser.GetAppId(), + }, + }, + + // Project Owner + { + testName: "when user is ProjectOwner deactivate app request should succeed", + inputCtx: projectOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForPrjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner deactivate app request should succeed", + inputCtx: OrgOwnerCtx, + deleteRequest: &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToDeactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.DeactivateApplication(tc.inputCtx, tc.deleteRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetDeactivationDate()) + } + }) + } +} + +func TestReactivateApplication(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForAppNameCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appToReactivate, appCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForAppNameCreation, + }) + require.Nil(t, appCreateErr) + + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + { + testName: "when app to reactivate is not found should return not found error", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: gofakeit.Sentence(2), + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when app to reactivate is found should return deactivation time", + inputCtx: IAMOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivate.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestReactivateApplication_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + appToReactivateForLoginUser := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForLoginUser, p.GetId()) + + appToReactivateForProjectOwner := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForProjectOwner, p.GetId()) + + appToReactivateForOrgOwner := createAPIApp(t, p.GetId()) + deactivateApp(t, appToReactivateForOrgOwner, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + reactivateRequest *app.ReactivateApplicationRequest + + expectedErrorType codes.Code + }{ + // Login User + { + testName: "when user has no project.app.write permission for app reactivate request should return permission error", + inputCtx: LoginUserCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForLoginUser.GetAppId(), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner reactivate app request should succeed", + inputCtx: projectOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForProjectOwner.GetAppId(), + }, + }, + + // Org Owner + { + testName: "when user is OrgOwner reactivate app request should succeed", + inputCtx: OrgOwnerCtx, + reactivateRequest: &app.ReactivateApplicationRequest{ + ProjectId: p.GetId(), + Id: appToReactivateForOrgOwner.GetAppId(), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.ReactivateApplication(tc.inputCtx, tc.reactivateRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetReactivationDate()) + } + }) + } +} + +func TestRegenerateClientSecret(t *testing.T) { + p := instance.CreateProject(IAMOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.Name(), false, false) + + reqForApiAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + apiAppToRegen, apiAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForApiAppCreation, + }) + require.Nil(t, apiAppCreateErr) + + reqForOIDCAppCreation := &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + } + + oidcAppToRegen, oidcAppCreateErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: gofakeit.AppName(), + CreationRequestType: reqForOIDCAppCreation, + }) + require.Nil(t, oidcAppCreateErr) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + { + testName: "when app to regen is not expected type should return invalid argument error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + }, + expectedErrorType: codes.InvalidArgument, + }, + { + testName: "when app to regen is not found should return not found error", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: gofakeit.Sentence(2), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when API app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegen.GetApiResponse().GetClientSecret(), + }, + { + testName: "when OIDC app to regen is found should return different secret", + inputCtx: IAMOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegen.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegen.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} + +func TestRegenerateClientSecret_WithDifferentPermissions(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppToRegenForLoginUser := createAPIApp(t, p.GetId()) + apiAppToRegenForProjectOwner := createAPIApp(t, p.GetId()) + apiAppToRegenForOrgOwner := createAPIApp(t, p.GetId()) + + oidcAppToRegenForLoginUser := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) + oidcAppToRegenForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + inputCtx context.Context + regenRequest *app.RegenerateClientSecretRequest + + expectedErrorType codes.Code + oldSecret string + }{ + // Login user + { + testName: "when user has no project.app.write permission for API app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + { + testName: "when user has no project.app.write permission for OIDC app secret regen request should return permission error", + inputCtx: LoginUserCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForLoginUser.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + expectedErrorType: codes.PermissionDenied, + }, + + // Project Owner + { + testName: "when user is ProjectOwner regen API app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForProjectOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is ProjectOwner regen OIDC app secret request should succeed", + inputCtx: projectOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForProjectOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForProjectOwner.GetOidcResponse().GetClientSecret(), + }, + + // Org Owner + { + testName: "when user is OrgOwner regen API app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: apiAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsApi{}, + }, + oldSecret: apiAppToRegenForOrgOwner.GetApiResponse().GetClientSecret(), + }, + { + testName: "when user is OrgOwner regen OIDC app secret request should succeed", + inputCtx: OrgOwnerCtx, + regenRequest: &app.RegenerateClientSecretRequest{ + ProjectId: p.GetId(), + ApplicationId: oidcAppToRegenForOrgOwner.GetAppId(), + AppType: &app.RegenerateClientSecretRequest_IsOidc{}, + }, + oldSecret: oidcAppToRegenForOrgOwner.GetOidcResponse().GetClientSecret(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + res, err := instance.Client.AppV2Beta.RegenerateClientSecret(tc.inputCtx, tc.regenRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetCreationDate()) + assert.NotEqual(t, tc.oldSecret, res.GetClientSecret()) + } + }) + } + +} diff --git a/internal/api/grpc/app/v2beta/integration_test/query_test.go b/internal/api/grpc/app/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..578fcec138 --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/query_test.go @@ -0,0 +1,575 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "slices" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" +) + +func TestGetApplication(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + apiAppName := gofakeit.AppName() + createdApiApp, errAPIAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: apiAppName, + CreationRequestType: &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{ + AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC, + }, + }, + }) + require.Nil(t, errAPIAppCreation) + + samlAppName := gofakeit.AppName() + createdSAMLApp, errSAMLAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: samlAppName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV1{LoginV1: &app.LoginV1{}}}, + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{MetadataXml: samlMetadataGen(gofakeit.URL())}, + }, + }, + }) + require.Nil(t, errSAMLAppCreation) + + oidcAppName := gofakeit.AppName() + createdOIDCApp, errOIDCAppCreation := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: oidcAppName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{Version: &app.LoginVersion_LoginV2{LoginV2: &app.LoginV2{BaseUri: &baseURI}}}, + }, + }, + }) + require.Nil(t, errOIDCAppCreation) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.GetApplicationRequest + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppName string + expectedAppID string + expectedApplicationType string + }{ + { + testName: "when unknown app ID should return not found error", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: gofakeit.Sentence(2), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when user has no permission should return membership not found error", + inputCtx: NoPermissionCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when providing API app ID should return valid API app result", + inputCtx: projectOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdApiApp.GetAppId(), + }, + + expectedAppName: apiAppName, + expectedAppID: createdApiApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_ApiConfig{}), + }, + { + testName: "when providing SAML app ID should return valid SAML app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdSAMLApp.GetAppId(), + }, + + expectedAppName: samlAppName, + expectedAppID: createdSAMLApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_SamlConfig{}), + }, + { + testName: "when providing OIDC app ID should return valid OIDC app result", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationRequest{ + Id: createdOIDCApp.GetAppId(), + }, + + expectedAppName: oidcAppName, + expectedAppID: createdOIDCApp.GetAppId(), + expectedApplicationType: fmt.Sprintf("%T", &app.Application_OidcConfig{}), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.GetApplication(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + + assert.Equal(t, tc.expectedAppID, res.GetApp().GetId()) + assert.Equal(t, tc.expectedAppName, res.GetApp().GetName()) + assert.NotZero(t, res.GetApp().GetCreationDate()) + assert.NotZero(t, res.GetApp().GetChangeDate()) + + appType := fmt.Sprintf("%T", res.GetApp().GetConfig()) + assert.Equal(t, tc.expectedApplicationType, appType) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + t.Parallel() + + createdApiApp, apiAppName := createAPIAppWithName(t, p.GetId()) + + createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, p.GetId()) + deactivateApp(t, createdDeactivatedApiApp, p.GetId()) + + _, createdSAMLApp, samlAppName := createSAMLAppWithName(t, gofakeit.URL(), p.GetId()) + + createdOIDCApp, oidcAppName := createOIDCAppWithName(t, gofakeit.URL(), p.GetId()) + + type appWithName struct { + app *app.CreateApplicationResponse + name string + } + + // Sorting + appsSortedByName := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByName, func(a, b appWithName) int { + if a.name < b.name { + return -1 + } + if a.name > b.name { + return 1 + } + + return 0 + }) + + appsSortedByID := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByID, func(a, b appWithName) int { + if a.app.GetAppId() < b.app.GetAppId() { + return -1 + } + if a.app.GetAppId() > b.app.GetAppId() { + return 1 + } + return 0 + }) + + appsSortedByCreationDate := []appWithName{ + {name: apiAppName, app: createdApiApp}, + {name: deactivatedApiAppName, app: createdDeactivatedApiApp}, + {name: samlAppName, app: createdSAMLApp}, + {name: oidcAppName, app: createdOIDCApp}, + } + slices.SortFunc(appsSortedByCreationDate, func(a, b appWithName) int { + aCreationDate := a.app.GetCreationDate().AsTime() + bCreationDate := b.app.GetCreationDate().AsTime() + + if aCreationDate.Before(bCreationDate) { + return -1 + } + if bCreationDate.Before(aCreationDate) { + return 1 + } + + return 0 + }) + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedOrderedList []appWithName + expectedOrderedKeys func(keys []appWithName) any + actualOrderedKeys func(keys []*app.Application) any + }{ + { + testName: "when no apps found should return empty list", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: "another-id", + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when user has no read permission should return empty set", + inputCtx: NoPermissionCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedOrderedList: []appWithName{}, + expectedOrderedKeys: func(keys []appWithName) any { return keys }, + actualOrderedKeys: func(keys []*app.Application) any { return keys }, + }, + { + testName: "when sorting by name should return apps sorted by name in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when user is project owner should return apps sorted by name in ascending order", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_NAME, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + + expectedOrderedList: appsSortedByName, + expectedOrderedKeys: func(apps []appWithName) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.name + } + + return names + }, + actualOrderedKeys: func(apps []*app.Application) any { + names := make([]string, len(apps)) + for i, a := range apps { + names[i] = a.GetName() + } + + return names + }, + }, + + { + testName: "when sorting by id should return apps sorted by id in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_ID, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByID, + expectedOrderedKeys: func(apps []appWithName) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.app.GetAppId() + } + + return ids + }, + actualOrderedKeys: func(apps []*app.Application) any { + ids := make([]string, len(apps)) + for i, a := range apps { + ids[i] = a.GetId() + } + + return ids + }, + }, + { + testName: "when sorting by creation date should return apps sorted by creation date in descending order", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + SortingColumn: app.AppSorting_APP_SORT_BY_CREATION_DATE, + Pagination: &filter.PaginationRequest{Asc: true}, + }, + expectedOrderedList: appsSortedByCreationDate, + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by active apps should return active apps only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_StateFilter{StateFilter: app.AppState_APP_STATE_ACTIVE}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name == deactivatedApiAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + { + testName: "when filtering by app type should return apps of matching type only", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + Pagination: &filter.PaginationRequest{Asc: true}, + Filters: []*app.ApplicationSearchFilter{ + {Filter: &app.ApplicationSearchFilter_OidcAppOnly{}}, + }, + }, + expectedOrderedList: slices.DeleteFunc( + slices.Clone(appsSortedByID), + func(a appWithName) bool { return a.name != oidcAppName }, + ), + expectedOrderedKeys: func(apps []appWithName) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.app.GetCreationDate().AsTime() + } + + return creationDates + }, + actualOrderedKeys: func(apps []*app.Application) any { + creationDates := make([]time.Time, len(apps)) + for i, a := range apps { + creationDates[i] = a.GetCreationDate().AsTime() + } + + return creationDates + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, codes.OK, status.Code(err)) + + if err == nil { + assert.Len(ttt, res.GetApplications(), len(tc.expectedOrderedList)) + actualOrderedKeys := tc.actualOrderedKeys(res.GetApplications()) + expectedOrderedKeys := tc.expectedOrderedKeys(tc.expectedOrderedList) + assert.ElementsMatch(ttt, expectedOrderedKeys, actualOrderedKeys) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplications_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + _, otherProjectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + + appName1, appName2, appName3 := gofakeit.AppName(), gofakeit.AppName(), gofakeit.AppName() + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + app1, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName1, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app2, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName2, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + app3, appAPIConfigChangeErr := instancePermissionV2.Client.AppV2Beta.CreateApplication(iamOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: p.GetId(), + Name: appName3, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationsRequest + inputCtx context.Context + + expectedCode codes.Code + expectedAppIDs []string + }{ + { + testName: "when user has no read permission should return empty set", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + { + testName: "when projectOwner should return full app list", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedCode: codes.OK, + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when orgOwner should return full app list", + inputCtx: instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeOrgOwner), + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when iamOwner user should return full app list", + inputCtx: iamOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{app1.GetAppId(), app2.GetAppId(), app3.GetAppId()}, + }, + { + testName: "when other projectOwner user should return empty list", + inputCtx: otherProjectOwnerCtx, + inputRequest: &app.ListApplicationsRequest{ + ProjectId: p.GetId(), + }, + + expectedAppIDs: []string{}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instancePermissionV2.Client.AppV2Beta.ListApplications(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedCode, status.Code(err)) + + if err == nil { + require.Len(ttt, res.GetApplications(), len(tc.expectedAppIDs)) + + resAppIDs := []string{} + for _, a := range res.GetApplications() { + resAppIDs = append(resAppIDs, a.GetId()) + } + + assert.ElementsMatch(ttt, tc.expectedAppIDs, resAppIDs) + } + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/server_test.go b/internal/api/grpc/app/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..6618ab0616 --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/server_test.go @@ -0,0 +1,205 @@ +//go:build integration + +package instance_test + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" + project_v2beta "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" +) + +var ( + NoPermissionCtx context.Context + LoginUserCtx context.Context + OrgOwnerCtx context.Context + IAMOwnerCtx context.Context + + instance *integration.Instance + instancePermissionV2 *integration.Instance + + baseURI = "http://example.com" +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + instance = integration.NewInstance(ctx) + instancePermissionV2 = integration.NewInstance(ctx) + + IAMOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeIAMOwner) + + LoginUserCtx = instance.WithAuthorization(ctx, integration.UserTypeLogin) + OrgOwnerCtx = instance.WithAuthorization(ctx, integration.UserTypeOrgOwner) + NoPermissionCtx = instance.WithAuthorization(ctx, integration.UserTypeNoPermission) + + return m.Run() + }()) +} + +func getProjectAndProjectContext(t *testing.T, inst *integration.Instance, ctx context.Context) (*project_v2beta.CreateProjectResponse, context.Context) { + project := inst.CreateProject(ctx, t, inst.DefaultOrg.GetId(), gofakeit.Name(), false, false) + userResp := inst.CreateMachineUser(ctx) + patResp := inst.CreatePersonalAccessToken(ctx, userResp.GetUserId()) + inst.CreateProjectMembership(t, ctx, project.GetId(), userResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(context.Background(), patResp.Token) + + return project, projectOwnerCtx +} + +func samlMetadataGen(entityID string) []byte { + str := fmt.Sprintf(` + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +`, + entityID) + + return []byte(str) +} + +func createSAMLAppWithName(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse, string) { + samlMetas := samlMetadataGen(gofakeit.URL()) + appName := gofakeit.AppName() + + appForSAMLConfigChange, appSAMLConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_SamlRequest{ + SamlRequest: &app.CreateSAMLApplicationRequest{ + Metadata: &app.CreateSAMLApplicationRequest_MetadataXml{ + MetadataXml: samlMetas, + }, + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appSAMLConfigChangeErr) + + return samlMetas, appForSAMLConfigChange, appName +} + +func createSAMLApp(t *testing.T, baseURI, projectID string) ([]byte, *app.CreateApplicationResponse) { + metas, app, _ := createSAMLAppWithName(t, baseURI, projectID) + return metas, app +} + +func createOIDCAppWithName(t *testing.T, baseURI, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + appForOIDCConfigChange, appOIDCConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: &app.CreateApplicationRequest_OidcRequest{ + OidcRequest: &app.CreateOIDCApplicationRequest{ + RedirectUris: []string{"http://example.com"}, + ResponseTypes: []app.OIDCResponseType{app.OIDCResponseType_OIDC_RESPONSE_TYPE_CODE}, + GrantTypes: []app.OIDCGrantType{app.OIDCGrantType_OIDC_GRANT_TYPE_AUTHORIZATION_CODE}, + AppType: app.OIDCAppType_OIDC_APP_TYPE_WEB, + AuthMethodType: app.OIDCAuthMethodType_OIDC_AUTH_METHOD_TYPE_BASIC, + PostLogoutRedirectUris: []string{"http://example.com/home"}, + Version: app.OIDCVersion_OIDC_VERSION_1_0, + AccessTokenType: app.OIDCTokenType_OIDC_TOKEN_TYPE_JWT, + BackChannelLogoutUri: "http://example.com/logout", + LoginVersion: &app.LoginVersion{ + Version: &app.LoginVersion_LoginV2{ + LoginV2: &app.LoginV2{ + BaseUri: &baseURI, + }, + }, + }, + }, + }, + }) + require.Nil(t, appOIDCConfigChangeErr) + + return appForOIDCConfigChange, appName +} + +func createOIDCApp(t *testing.T, baseURI, projctID string) *app.CreateApplicationResponse { + app, _ := createOIDCAppWithName(t, baseURI, projctID) + + return app +} + +func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicationResponse, string) { + appName := gofakeit.AppName() + + reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ + ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, + } + + appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + ProjectId: projectID, + Name: appName, + CreationRequestType: reqForAPIAppCreation, + }) + require.Nil(t, appAPIConfigChangeErr) + + return appForAPIConfigChange, appName +} + +func createAPIApp(t *testing.T, projectID string) *app.CreateApplicationResponse { + res, _ := createAPIAppWithName(t, projectID) + return res +} + +func deactivateApp(t *testing.T, appToDeactivate *app.CreateApplicationResponse, projectID string) { + _, appDeactivateErr := instance.Client.AppV2Beta.DeactivateApplication(IAMOwnerCtx, &app.DeactivateApplicationRequest{ + ProjectId: projectID, + Id: appToDeactivate.GetAppId(), + }) + require.Nil(t, appDeactivateErr) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + + if f.PermissionCheckV2.GetEnabled() { + return + } + + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{Inheritance: true}) + require.NoError(tt, err) + assert.True(tt, f.PermissionCheckV2.GetEnabled()) + }, retryDuration, tick, "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/app/v2beta/query.go b/internal/api/grpc/app/v2beta/query.go new file mode 100644 index 0000000000..add8af83e6 --- /dev/null +++ b/internal/api/grpc/app/v2beta/query.go @@ -0,0 +1,37 @@ +package app + +import ( + "context" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) GetApplication(ctx context.Context, req *app.GetApplicationRequest) (*app.GetApplicationResponse, error) { + res, err := s.query.AppByIDWithPermission(ctx, req.GetId(), false, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.GetApplicationResponse{ + App: convert.AppToPb(res), + }, nil +} + +func (s *Server) ListApplications(ctx context.Context, req *app.ListApplicationsRequest) (*app.ListApplicationsResponse, error) { + queries, err := convert.ListApplicationsRequestToModel(s.systemDefaults, req) + if err != nil { + return nil, err + } + + res, err := s.query.SearchApps(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.ListApplicationsResponse{ + Applications: convert.AppsToPb(res.Apps), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/server.go b/internal/api/grpc/app/v2beta/server.go new file mode 100644 index 0000000000..8343cbe404 --- /dev/null +++ b/internal/api/grpc/app/v2beta/server.go @@ -0,0 +1,57 @@ +package app + +import ( + "google.golang.org/grpc" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +var _ app.AppServiceServer = (*Server)(nil) + +type Server struct { + app.UnimplementedAppServiceServer + command *command.Commands + query *query.Queries + systemDefaults systemdefaults.SystemDefaults + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterServer(grpcServer *grpc.Server) { + app.RegisterAppServiceServer(grpcServer, s) +} + +func (s *Server) AppName() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return app.AppService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return app.AppService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return app.RegisterAppServiceHandler +} diff --git a/internal/api/grpc/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go index 7a7d7cd8d7..f797ad4bba 100644 --- a/internal/api/grpc/filter/v2/converter.go +++ b/internal/api/grpc/filter/v2/converter.go @@ -48,3 +48,26 @@ func QueryToPaginationPb(request query.SearchRequest, response query.SearchRespo TotalResult: response.Count, } } + +func TextMethodPbToQuery(method filter.TextFilterMethod) query.TextComparison { + switch method { + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS: + return query.TextEquals + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE: + return query.TextEqualsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH: + return query.TextStartsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE: + return query.TextStartsWithIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS: + return query.TextContains + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE: + return query.TextContainsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH: + return query.TextEndsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE: + return query.TextEndsWithIgnoreCase + default: + return -1 + } +} diff --git a/internal/api/grpc/management/project_application.go b/internal/api/grpc/management/project_application.go index ab49905409..a5526d3cb7 100644 --- a/internal/api/grpc/management/project_application.go +++ b/internal/api/grpc/management/project_application.go @@ -29,7 +29,7 @@ func (s *Server) ListApps(ctx context.Context, req *mgmt_pb.ListAppsRequest) (*m if err != nil { return nil, err } - apps, err := s.query.SearchApps(ctx, queries, false) + apps, err := s.query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } @@ -125,7 +125,7 @@ func (s *Server) AddAPIApp(ctx context.Context, req *mgmt_pb.AddAPIAppRequest) ( } func (s *Server) UpdateApp(ctx context.Context, req *mgmt_pb.UpdateAppRequest) (*mgmt_pb.UpdateAppResponse, error) { - details, err := s.command.ChangeApplication(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + details, err := s.command.UpdateApplicationName(ctx, req.ProjectId, UpdateAppRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -139,7 +139,7 @@ func (s *Server) UpdateOIDCAppConfig(ctx context.Context, req *mgmt_pb.UpdateOID if err != nil { return nil, err } - config, err := s.command.ChangeOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateOIDCApplication(ctx, oidcApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM if err != nil { return nil, err } - config, err := s.command.ChangeSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateSAMLApplication(ctx, samlApp, authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -171,7 +171,7 @@ func (s *Server) UpdateSAMLAppConfig(ctx context.Context, req *mgmt_pb.UpdateSAM } func (s *Server) UpdateAPIAppConfig(ctx context.Context, req *mgmt_pb.UpdateAPIAppConfigRequest) (*mgmt_pb.UpdateAPIAppConfigResponse, error) { - config, err := s.command.ChangeAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + config, err := s.command.UpdateAPIApplication(ctx, UpdateAPIAppConfigRequestToDomain(req), authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 13a0048a5b..186cedc933 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/api/authz" authn_grpc "github.com/zitadel/zitadel/internal/api/grpc/authn" "github.com/zitadel/zitadel/internal/api/grpc/object" @@ -46,24 +48,24 @@ func AddOIDCAppRequestToDomain(req *mgmt_pb.AddOIDCAppRequest) (*domain.OIDCApp, AggregateID: req.ProjectId, }, AppName: req.Name, - OIDCVersion: app_grpc.OIDCVersionToDomain(req.Version), + OIDCVersion: gu.Ptr(app_grpc.OIDCVersionToDomain(req.Version)), RedirectUris: req.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(req.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(req.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(req.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(req.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(req.AuthMethodType)), PostLogoutRedirectUris: req.PostLogoutRedirectUris, - DevMode: req.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType), - AccessTokenRoleAssertion: req.AccessTokenRoleAssertion, - IDTokenRoleAssertion: req.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: req.IdTokenUserinfoAssertion, - ClockSkew: req.ClockSkew.AsDuration(), + DevMode: gu.Ptr(req.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(req.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(req.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(req.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(req.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(req.GetClockSkew().AsDuration()), AdditionalOrigins: req.AdditionalOrigins, - SkipNativeAppSuccessPage: req.SkipNativeAppSuccessPage, - BackChannelLogoutURI: req.GetBackChannelLogoutUri(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(req.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(req.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -78,9 +80,9 @@ func AddSAMLAppRequestToDomain(req *mgmt_pb.AddSAMLAppRequest) (*domain.SAMLApp, }, AppName: req.Name, Metadata: req.GetMetadataXml(), - MetadataURL: req.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(req.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -114,20 +116,20 @@ func UpdateOIDCAppConfigRequestToDomain(app *mgmt_pb.UpdateOIDCAppConfigRequest) RedirectUris: app.RedirectUris, ResponseTypes: app_grpc.OIDCResponseTypesToDomain(app.ResponseTypes), GrantTypes: app_grpc.OIDCGrantTypesToDomain(app.GrantTypes), - ApplicationType: app_grpc.OIDCApplicationTypeToDomain(app.AppType), - AuthMethodType: app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType), + ApplicationType: gu.Ptr(app_grpc.OIDCApplicationTypeToDomain(app.AppType)), + AuthMethodType: gu.Ptr(app_grpc.OIDCAuthMethodTypeToDomain(app.AuthMethodType)), PostLogoutRedirectUris: app.PostLogoutRedirectUris, - DevMode: app.DevMode, - AccessTokenType: app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType), - AccessTokenRoleAssertion: app.AccessTokenRoleAssertion, - IDTokenRoleAssertion: app.IdTokenRoleAssertion, - IDTokenUserinfoAssertion: app.IdTokenUserinfoAssertion, - ClockSkew: app.ClockSkew.AsDuration(), + DevMode: gu.Ptr(app.GetDevMode()), + AccessTokenType: gu.Ptr(app_grpc.OIDCTokenTypeToDomain(app.AccessTokenType)), + AccessTokenRoleAssertion: gu.Ptr(app.GetAccessTokenRoleAssertion()), + IDTokenRoleAssertion: gu.Ptr(app.GetIdTokenRoleAssertion()), + IDTokenUserinfoAssertion: gu.Ptr(app.GetIdTokenUserinfoAssertion()), + ClockSkew: gu.Ptr(app.GetClockSkew().AsDuration()), AdditionalOrigins: app.AdditionalOrigins, - SkipNativeAppSuccessPage: app.SkipNativeAppSuccessPage, - BackChannelLogoutURI: app.BackChannelLogoutUri, - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(app.GetSkipNativeAppSuccessPage()), + BackChannelLogoutURI: gu.Ptr(app.GetBackChannelLogoutUri()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } @@ -142,9 +144,9 @@ func UpdateSAMLAppConfigRequestToDomain(app *mgmt_pb.UpdateSAMLAppConfigRequest) }, AppID: app.AppId, Metadata: app.GetMetadataXml(), - MetadataURL: app.GetMetadataUrl(), - LoginVersion: loginVersion, - LoginBaseURI: loginBaseURI, + MetadataURL: gu.Ptr(app.GetMetadataUrl()), + LoginVersion: gu.Ptr(loginVersion), + LoginBaseURI: gu.Ptr(loginBaseURI), }, nil } diff --git a/internal/authz/repository/eventsourcing/view/application.go b/internal/authz/repository/eventsourcing/view/application.go index 8db8ec8e39..7fa920bcfe 100644 --- a/internal/authz/repository/eventsourcing/view/application.go +++ b/internal/authz/repository/eventsourcing/view/application.go @@ -32,7 +32,7 @@ func (v *View) ApplicationByProjecIDAndAppName(ctx context.Context, projectID, a }, } - apps, err := v.Query.SearchApps(ctx, queries, false) + apps, err := v.Query.SearchApps(ctx, queries, nil) if err != nil { return nil, err } diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 6bfeaae219..3f978b6618 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -85,3 +85,11 @@ func (c *Commands) checkPermissionDeleteProjectGrant(ctx context.Context, resour } return nil } + +func (c *Commands) checkPermissionUpdateApplication(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppWrite, project.AggregateType)(resourceOwner, appID) +} + +func (c *Commands) checkPermissionDeleteApp(ctx context.Context, resourceOwner, appID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectAppDelete, project.AggregateType)(resourceOwner, appID) +} diff --git a/internal/command/project_application.go b/internal/command/project_application.go index 0ccf5dc852..465b12e1e1 100644 --- a/internal/command/project_application.go +++ b/internal/command/project_application.go @@ -15,7 +15,7 @@ type AddApp struct { Name string } -func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { +func (c *Commands) UpdateApplicationName(ctx context.Context, projectID string, appChange domain.Application, resourceOwner string) (*domain.ObjectDetails, error) { if projectID == "" || appChange.GetAppID() == "" || appChange.GetApplicationName() == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-4m9vS", "Errors.Project.App.Invalid") } @@ -30,6 +30,13 @@ func (c *Commands) ChangeApplication(ctx context.Context, projectID string, appC if existingApp.Name == appChange.GetApplicationName() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-2m8vx", "Errors.NoChangesFound") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push( ctx, @@ -59,6 +66,13 @@ func (c *Commands) DeactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateActive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-dsh35", "Errors.Project.App.NotActive") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationDeactivatedEvent(ctx, projectAgg, appID)) if err != nil { @@ -86,6 +100,11 @@ func (c *Commands) ReactivateApplication(ctx context.Context, projectID, appID, if existingApp.State != domain.AppStateInactive { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1n8cM", "Errors.Project.App.NotInactive") } + + if err := c.checkPermissionUpdateApplication(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationReactivatedEvent(ctx, projectAgg, appID)) @@ -111,6 +130,13 @@ func (c *Commands) RemoveApplication(ctx context.Context, projectID, appID, reso if existingApp.State == domain.AppStateUnspecified || existingApp.State == domain.AppStateRemoved { return nil, zerrors.ThrowNotFound(nil, "COMMAND-0po9s", "Errors.Project.App.NotExisting") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingApp); err != nil { + return nil, err + } + if err := c.checkPermissionDeleteApp(ctx, existingApp.ResourceOwner, existingApp.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingApp.WriteModel) entityID := "" diff --git a/internal/command/project_application_api.go b/internal/command/project_application_api.go index 2832dcf873..82e7d0bde8 100644 --- a/internal/command/project_application_api.go +++ b/internal/command/project_application_api.go @@ -90,16 +90,24 @@ func (c *Commands) AddAPIApplication(ctx context.Context, apiApp *domain.APIApp, return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-5m9E", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, apiApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if !apiApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-Bff2g", "Errors.Project.App.Invalid") } - appID, err := c.idGenerator.Next() - if err != nil { - return nil, err + appID := apiApp.AppID + if appID == "" { + appID, err = c.idGenerator.Next() + if err != nil { + return nil, err + } } return c.addAPIApplicationWithID(ctx, apiApp, resourceOwner, appID) @@ -112,6 +120,13 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A apiApp.AppID = appID addedApplication := NewAPIApplicationWriteModel(apiApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events := []eventstore.Command{ @@ -150,7 +165,7 @@ func (c *Commands) addAPIApplicationWithID(ctx context.Context, apiApp *domain.A return result, nil } -func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { +func (c *Commands) UpdateAPIApplication(ctx context.Context, apiApp *domain.APIApp, resourceOwner string) (*domain.APIApp, error) { if apiApp.AppID == "" || apiApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-1m900", "Errors.Project.App.APIConfigInvalid") } @@ -165,6 +180,13 @@ func (c *Commands) ChangeAPIApplication(ctx context.Context, apiApp *domain.APIA if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Gnwt3", "Errors.Project.App.IsNotAPI") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingAPI); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingAPI.WriteModel) changedEvent, hasChanged, err := existingAPI.NewChangedEvent( ctx, @@ -205,6 +227,11 @@ func (c *Commands) ChangeAPIApplicationSecret(ctx context.Context, projectID, ap if !existingAPI.IsAPI() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-aeH4", "Errors.Project.App.IsNotAPI") } + + if err := c.checkPermissionUpdateApplication(ctx, existingAPI.ResourceOwner, existingAPI.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err diff --git a/internal/command/project_application_api_test.go b/internal/command/project_application_api_test.go index a6d4349254..53448e1c5e 100644 --- a/internal/command/project_application_api_test.go +++ b/internal/command/project_application_api_test.go @@ -142,6 +142,7 @@ func TestAddAPIConfig(t *testing.T) { } func TestCommandSide_AddAPIApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -238,6 +239,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -292,6 +294,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -346,6 +349,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -390,6 +394,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -397,6 +403,7 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.AddAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { @@ -413,6 +420,8 @@ func TestCommandSide_AddAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore } @@ -516,6 +525,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypePrivateKeyJWT), ), ), + expectFilter(), ), }, args: args{ @@ -555,6 +565,7 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { domain.APIAuthMethodTypeBasic), ), ), + expectFilter(), expectPush( newAPIAppChangedEvent(context.Background(), "app1", @@ -593,14 +604,17 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) + got, err := r.UpdateAPIApplication(tt.args.ctx, tt.args.apiApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -615,6 +629,8 @@ func TestCommandSide_ChangeAPIApplication(t *testing.T) { } func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -734,12 +750,15 @@ func TestCommandSide_ChangeAPIApplicationSecret(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeAPIApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_application_oidc.go b/internal/command/project_application_oidc.go index 77ef7ff0c7..7f33b6a3cf 100644 --- a/internal/command/project_application_oidc.go +++ b/internal/command/project_application_oidc.go @@ -5,6 +5,8 @@ import ( "strings" "time" + "github.com/muhlemmer/gu" + http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" @@ -120,6 +122,7 @@ func (c *Commands) AddOIDCAppCommand(app *addOIDCApp) preparation.Validation { } } +// TODO: Combine with AddOIDCApplication and addOIDCApplicationWithID func (c *Commands) AddOIDCApplicationWithID(ctx context.Context, oidcApp *domain.OIDCApp, resourceOwner, appID string) (_ *domain.OIDCApp, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -142,9 +145,15 @@ func (c *Commands) AddOIDCApplication(ctx context.Context, oidcApp *domain.OIDCA if oidcApp == nil || oidcApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-34Fm0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner); err != nil { + + projectResOwner, err := c.checkProjectExists(ctx, oidcApp.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + if oidcApp.AppName == "" || !oidcApp.IsValid() { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n8df", "Errors.Project.App.Invalid") } @@ -162,6 +171,13 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain defer func() { span.EndWithError(err) }() addedApplication := NewOIDCApplicationWriteModel(oidcApp.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) oidcApp.AppID = appID @@ -183,27 +199,27 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain } events = append(events, project_repo.NewOIDCConfigAddedEvent(ctx, projectAgg, - oidcApp.OIDCVersion, + gu.Value(oidcApp.OIDCVersion), oidcApp.AppID, oidcApp.ClientID, oidcApp.EncodedHash, trimStringSliceWhiteSpaces(oidcApp.RedirectUris), oidcApp.ResponseTypes, oidcApp.GrantTypes, - oidcApp.ApplicationType, - oidcApp.AuthMethodType, + gu.Value(oidcApp.ApplicationType), + gu.Value(oidcApp.AuthMethodType), trimStringSliceWhiteSpaces(oidcApp.PostLogoutRedirectUris), - oidcApp.DevMode, - oidcApp.AccessTokenType, - oidcApp.AccessTokenRoleAssertion, - oidcApp.IDTokenRoleAssertion, - oidcApp.IDTokenUserinfoAssertion, - oidcApp.ClockSkew, + gu.Value(oidcApp.DevMode), + gu.Value(oidcApp.AccessTokenType), + gu.Value(oidcApp.AccessTokenRoleAssertion), + gu.Value(oidcApp.IDTokenRoleAssertion), + gu.Value(oidcApp.IDTokenUserinfoAssertion), + gu.Value(oidcApp.ClockSkew), trimStringSliceWhiteSpaces(oidcApp.AdditionalOrigins), - oidcApp.SkipNativeAppSuccessPage, - strings.TrimSpace(oidcApp.BackChannelLogoutURI), - oidcApp.LoginVersion, - strings.TrimSpace(oidcApp.LoginBaseURI), + gu.Value(oidcApp.SkipNativeAppSuccessPage), + strings.TrimSpace(gu.Value(oidcApp.BackChannelLogoutURI)), + gu.Value(oidcApp.LoginVersion), + strings.TrimSpace(gu.Value(oidcApp.LoginBaseURI)), )) addedApplication.AppID = oidcApp.AppID @@ -226,7 +242,7 @@ func (c *Commands) addOIDCApplicationWithID(ctx context.Context, oidcApp *domain return result, nil } -func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { +func (c *Commands) UpdateOIDCApplication(ctx context.Context, oidc *domain.OIDCApp, resourceOwner string) (*domain.OIDCApp, error) { if !oidc.IsValid() || oidc.AppID == "" || oidc.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5m9fs", "Errors.Project.App.OIDCConfigInvalid") } @@ -241,7 +257,23 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr34", "Errors.Project.App.IsNotOIDC") } + if err := c.eventstore.FilterToQueryReducer(ctx, existingOIDC); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingOIDC.WriteModel) + var backChannelLogout, loginBaseURI *string + if oidc.BackChannelLogoutURI != nil { + backChannelLogout = gu.Ptr(strings.TrimSpace(*oidc.BackChannelLogoutURI)) + } + + if oidc.LoginBaseURI != nil { + loginBaseURI = gu.Ptr(strings.TrimSpace(*oidc.LoginBaseURI)) + } + changedEvent, hasChanged, err := existingOIDC.NewChangedEvent( ctx, projectAgg, @@ -261,9 +293,9 @@ func (c *Commands) ChangeOIDCApplication(ctx context.Context, oidc *domain.OIDCA oidc.ClockSkew, trimStringSliceWhiteSpaces(oidc.AdditionalOrigins), oidc.SkipNativeAppSuccessPage, - strings.TrimSpace(oidc.BackChannelLogoutURI), + backChannelLogout, oidc.LoginVersion, - strings.TrimSpace(oidc.LoginBaseURI), + loginBaseURI, ) if err != nil { return nil, err @@ -301,6 +333,11 @@ func (c *Commands) ChangeOIDCApplicationSecret(ctx context.Context, projectID, a if !existingOIDC.IsOIDC() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Ghrh3", "Errors.Project.App.IsNotOIDC") } + + if err := c.checkPermissionUpdateApplication(ctx, existingOIDC.ResourceOwner, existingOIDC.AggregateID); err != nil { + return nil, err + } + encodedHash, plain, err := c.newHashedSecret(ctx, c.eventstore.Filter) //nolint:staticcheck if err != nil { return nil, err diff --git a/internal/command/project_application_oidc_model.go b/internal/command/project_application_oidc_model.go index 603ebdcda2..375bb26f5e 100644 --- a/internal/command/project_application_oidc_model.go +++ b/internal/command/project_application_oidc_model.go @@ -258,77 +258,77 @@ func (wm *OIDCApplicationWriteModel) NewChangedEvent( postLogoutRedirectURIs []string, responseTypes []domain.OIDCResponseType, grantTypes []domain.OIDCGrantType, - appType domain.OIDCApplicationType, - authMethodType domain.OIDCAuthMethodType, - oidcVersion domain.OIDCVersion, - accessTokenType domain.OIDCTokenType, + appType *domain.OIDCApplicationType, + authMethodType *domain.OIDCAuthMethodType, + oidcVersion *domain.OIDCVersion, + accessTokenType *domain.OIDCTokenType, devMode, accessTokenRoleAssertion, idTokenRoleAssertion, - idTokenUserinfoAssertion bool, - clockSkew time.Duration, + idTokenUserinfoAssertion *bool, + clockSkew *time.Duration, additionalOrigins []string, - skipNativeAppSuccessPage bool, - backChannelLogoutURI string, - loginVersion domain.LoginVersion, - loginBaseURI string, + skipNativeAppSuccessPage *bool, + backChannelLogoutURI *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.OIDCConfigChangedEvent, bool, error) { changes := make([]project.OIDCConfigChanges, 0) var err error - if !slices.Equal(wm.RedirectUris, redirectURIS) { + if redirectURIS != nil && !slices.Equal(wm.RedirectUris, redirectURIS) { changes = append(changes, project.ChangeRedirectURIs(redirectURIS)) } - if !slices.Equal(wm.ResponseTypes, responseTypes) { + if responseTypes != nil && !slices.Equal(wm.ResponseTypes, responseTypes) { changes = append(changes, project.ChangeResponseTypes(responseTypes)) } - if !slices.Equal(wm.GrantTypes, grantTypes) { + if grantTypes != nil && !slices.Equal(wm.GrantTypes, grantTypes) { changes = append(changes, project.ChangeGrantTypes(grantTypes)) } - if wm.ApplicationType != appType { - changes = append(changes, project.ChangeApplicationType(appType)) + if appType != nil && wm.ApplicationType != *appType { + changes = append(changes, project.ChangeApplicationType(*appType)) } - if wm.AuthMethodType != authMethodType { - changes = append(changes, project.ChangeAuthMethodType(authMethodType)) + if authMethodType != nil && wm.AuthMethodType != *authMethodType { + changes = append(changes, project.ChangeAuthMethodType(*authMethodType)) } - if !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { + if postLogoutRedirectURIs != nil && !slices.Equal(wm.PostLogoutRedirectUris, postLogoutRedirectURIs) { changes = append(changes, project.ChangePostLogoutRedirectURIs(postLogoutRedirectURIs)) } - if wm.OIDCVersion != oidcVersion { - changes = append(changes, project.ChangeVersion(oidcVersion)) + if oidcVersion != nil && wm.OIDCVersion != *oidcVersion { + changes = append(changes, project.ChangeVersion(*oidcVersion)) } - if wm.DevMode != devMode { - changes = append(changes, project.ChangeDevMode(devMode)) + if devMode != nil && wm.DevMode != *devMode { + changes = append(changes, project.ChangeDevMode(*devMode)) } - if wm.AccessTokenType != accessTokenType { - changes = append(changes, project.ChangeAccessTokenType(accessTokenType)) + if accessTokenType != nil && wm.AccessTokenType != *accessTokenType { + changes = append(changes, project.ChangeAccessTokenType(*accessTokenType)) } - if wm.AccessTokenRoleAssertion != accessTokenRoleAssertion { - changes = append(changes, project.ChangeAccessTokenRoleAssertion(accessTokenRoleAssertion)) + if accessTokenRoleAssertion != nil && wm.AccessTokenRoleAssertion != *accessTokenRoleAssertion { + changes = append(changes, project.ChangeAccessTokenRoleAssertion(*accessTokenRoleAssertion)) } - if wm.IDTokenRoleAssertion != idTokenRoleAssertion { - changes = append(changes, project.ChangeIDTokenRoleAssertion(idTokenRoleAssertion)) + if idTokenRoleAssertion != nil && wm.IDTokenRoleAssertion != *idTokenRoleAssertion { + changes = append(changes, project.ChangeIDTokenRoleAssertion(*idTokenRoleAssertion)) } - if wm.IDTokenUserinfoAssertion != idTokenUserinfoAssertion { - changes = append(changes, project.ChangeIDTokenUserinfoAssertion(idTokenUserinfoAssertion)) + if idTokenUserinfoAssertion != nil && wm.IDTokenUserinfoAssertion != *idTokenUserinfoAssertion { + changes = append(changes, project.ChangeIDTokenUserinfoAssertion(*idTokenUserinfoAssertion)) } - if wm.ClockSkew != clockSkew { - changes = append(changes, project.ChangeClockSkew(clockSkew)) + if clockSkew != nil && wm.ClockSkew != *clockSkew { + changes = append(changes, project.ChangeClockSkew(*clockSkew)) } - if !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { + if additionalOrigins != nil && !slices.Equal(wm.AdditionalOrigins, additionalOrigins) { changes = append(changes, project.ChangeAdditionalOrigins(additionalOrigins)) } - if wm.SkipNativeAppSuccessPage != skipNativeAppSuccessPage { - changes = append(changes, project.ChangeSkipNativeAppSuccessPage(skipNativeAppSuccessPage)) + if skipNativeAppSuccessPage != nil && wm.SkipNativeAppSuccessPage != *skipNativeAppSuccessPage { + changes = append(changes, project.ChangeSkipNativeAppSuccessPage(*skipNativeAppSuccessPage)) } - if wm.BackChannelLogoutURI != backChannelLogoutURI { - changes = append(changes, project.ChangeBackChannelLogoutURI(backChannelLogoutURI)) + if backChannelLogoutURI != nil && wm.BackChannelLogoutURI != *backChannelLogoutURI { + changes = append(changes, project.ChangeBackChannelLogoutURI(*backChannelLogoutURI)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeOIDCLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeOIDCLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeOIDCLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeOIDCLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_oidc_test.go b/internal/command/project_application_oidc_test.go index d0383b1b29..d728ffca45 100644 --- a/internal/command/project_application_oidc_test.go +++ b/internal/command/project_application_oidc_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -401,6 +402,8 @@ func TestAddOIDCApp(t *testing.T) { } func TestCommandSide_AddOIDCApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -497,6 +500,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -538,24 +542,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{" https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -569,24 +573,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -604,6 +608,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -645,24 +650,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -676,24 +681,24 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { AppName: "app", ClientID: "client1", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), State: domain.AppStateActive, Compliance: &domain.Compliance{}, }, @@ -702,6 +707,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() c := &Commands{ eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -709,6 +715,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) @@ -726,6 +733,7 @@ func TestCommandSide_AddOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplication(t *testing.T) { + t.Parallel() type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -775,7 +783,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -797,7 +805,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "", }, AppID: "appid", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -821,7 +829,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AggregateID: "project1", }, AppID: "app1", - AuthMethodType: domain.OIDCAuthMethodTypePost, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, @@ -870,6 +878,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -880,24 +889,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://login.test.ch"), }, resourceOwner: "org1", }, @@ -944,6 +953,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), ), }, args: args{ @@ -954,24 +964,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, AppID: "app1", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch "}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{" https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{" https://sub.test.ch "}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: " https://test.ch/backchannel ", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: " https://login.test.ch ", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr(" https://test.ch/backchannel "), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr(" https://login.test.ch "), }, resourceOwner: "org1", }, @@ -980,7 +990,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { }, }, { - name: "change oidc app, ok", + name: "partial change oidc app, ok", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -1018,6 +1028,7 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ), ), ), + expectFilter(), expectPush( newOIDCAppChangedEvent(context.Background(), "app1", @@ -1032,26 +1043,11 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { ObjectRoot: models.ObjectRoot{ AggregateID: "project1", }, - AppID: "app1", - AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{" https://test-change.ch "}, - ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, - GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{" https://test-change.ch/logout "}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, - AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + AppID: "app1", + AppName: "app", + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, + ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, }, resourceOwner: "org1", }, @@ -1064,24 +1060,24 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { AppID: "app1", ClientID: "client1@project", AppName: "app", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, - RedirectUris: []string{"https://test-change.ch"}, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypeBasic), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), + RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, - PostLogoutRedirectUris: []string{"https://test-change.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeJWT, - AccessTokenRoleAssertion: false, - IDTokenRoleAssertion: false, - IDTokenUserinfoAssertion: false, - ClockSkew: time.Second * 2, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), + PostLogoutRedirectUris: []string{"https://test.ch/logout"}, + DevMode: gu.Ptr(false), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: true, - BackChannelLogoutURI: "https://test.ch/backchannel", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://login.test.ch", + SkipNativeAppSuccessPage: gu.Ptr(true), + BackChannelLogoutURI: gu.Ptr("https://test.ch/backchannel"), + LoginVersion: gu.Ptr(domain.LoginVersion1), + LoginBaseURI: gu.Ptr(""), Compliance: &domain.Compliance{}, State: domain.AppStateActive, }, @@ -1090,10 +1086,12 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // t.Parallel() r := &Commands{ - eventstore: tt.fields.eventstore(t), + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) + got, err := r.UpdateOIDCApplication(tt.args.ctx, tt.args.oidcApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -1108,6 +1106,8 @@ func TestCommandSide_ChangeOIDCApplication(t *testing.T) { } func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(*testing.T) *eventstore.Eventstore } @@ -1237,36 +1237,40 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { AppName: "app", ClientID: "client1@project", ClientSecretString: "secret", - AuthMethodType: domain.OIDCAuthMethodTypePost, - OIDCVersion: domain.OIDCVersionV1, + AuthMethodType: gu.Ptr(domain.OIDCAuthMethodTypePost), + OIDCVersion: gu.Ptr(domain.OIDCVersionV1), RedirectUris: []string{"https://test.ch"}, ResponseTypes: []domain.OIDCResponseType{domain.OIDCResponseTypeCode}, GrantTypes: []domain.OIDCGrantType{domain.OIDCGrantTypeAuthorizationCode}, - ApplicationType: domain.OIDCApplicationTypeWeb, + ApplicationType: gu.Ptr(domain.OIDCApplicationTypeWeb), PostLogoutRedirectUris: []string{"https://test.ch/logout"}, - DevMode: true, - AccessTokenType: domain.OIDCTokenTypeBearer, - AccessTokenRoleAssertion: true, - IDTokenRoleAssertion: true, - IDTokenUserinfoAssertion: true, - ClockSkew: time.Second * 1, + DevMode: gu.Ptr(true), + AccessTokenType: gu.Ptr(domain.OIDCTokenTypeBearer), + AccessTokenRoleAssertion: gu.Ptr(true), + IDTokenRoleAssertion: gu.Ptr(true), + IDTokenUserinfoAssertion: gu.Ptr(true), + ClockSkew: gu.Ptr(time.Second * 1), AdditionalOrigins: []string{"https://sub.test.ch"}, - SkipNativeAppSuccessPage: false, - BackChannelLogoutURI: "", - LoginVersion: domain.LoginVersionUnspecified, + SkipNativeAppSuccessPage: gu.Ptr(false), + BackChannelLogoutURI: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), State: domain.AppStateActive, }, }, }, } for _, tt := range tests { - t.Run(tt.name, func(*testing.T) { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ eventstore: tt.fields.eventstore(t), newHashedSecret: mockHashedSecret("secret"), defaultSecretGenerators: &SecretGenerators{ ClientSecret: emptyConfig, }, + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ChangeOIDCApplicationSecret(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -1284,16 +1288,7 @@ func TestCommandSide_ChangeOIDCApplicationSecret(t *testing.T) { func newOIDCAppChangedEvent(ctx context.Context, appID, projectID, resourceOwner string) *project.OIDCConfigChangedEvent { changes := []project.OIDCConfigChanges{ - project.ChangeRedirectURIs([]string{"https://test-change.ch"}), - project.ChangePostLogoutRedirectURIs([]string{"https://test-change.ch/logout"}), - project.ChangeDevMode(true), - project.ChangeAccessTokenType(domain.OIDCTokenTypeJWT), - project.ChangeAccessTokenRoleAssertion(false), - project.ChangeIDTokenRoleAssertion(false), - project.ChangeIDTokenUserinfoAssertion(false), - project.ChangeClockSkew(time.Second * 2), - project.ChangeOIDCLoginVersion(domain.LoginVersion2), - project.ChangeOIDCLoginBaseURI("https://login.test.ch"), + project.ChangeAuthMethodType(domain.OIDCAuthMethodTypeBasic), } event, _ := project.NewOIDCConfigChangedEvent(ctx, &project.NewAggregate(projectID, resourceOwner).Aggregate, diff --git a/internal/command/project_application_saml.go b/internal/command/project_application_saml.go index 1a5cefa221..9b1dc9e97a 100644 --- a/internal/command/project_application_saml.go +++ b/internal/command/project_application_saml.go @@ -3,6 +3,7 @@ package command import ( "context" + "github.com/muhlemmer/gu" "github.com/zitadel/saml/pkg/provider/xml" "github.com/zitadel/zitadel/internal/domain" @@ -16,10 +17,22 @@ func (c *Commands) AddSAMLApplication(ctx context.Context, application *domain.S return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-35Fn0", "Errors.Project.App.Invalid") } - if _, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner); err != nil { + projectResOwner, err := c.checkProjectExists(ctx, application.AggregateID, resourceOwner) + if err != nil { return nil, err } + if resourceOwner == "" { + resourceOwner = projectResOwner + } + addedApplication := NewSAMLApplicationWriteModel(application.AggregateID, resourceOwner) + if err := c.eventstore.FilterToQueryReducer(ctx, addedApplication); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateApplication(ctx, addedApplication.ResourceOwner, addedApplication.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&addedApplication.WriteModel) events, err := c.addSAMLApplication(ctx, projectAgg, application) if err != nil { @@ -49,12 +62,8 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-1n9df", "Errors.Project.App.Invalid") } - if samlApp.Metadata == nil && samlApp.MetadataURL == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "SAML-podix9", "Errors.Project.App.SAMLMetadataMissing") - } - - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-wmqlo1", "Errors.Project.App.SAMLMetadataMissing") } @@ -78,14 +87,14 @@ func (c *Commands) addSAMLApplication(ctx context.Context, projectAgg *eventstor samlApp.AppID, string(entity.EntityID), samlApp.Metadata, - samlApp.MetadataURL, - samlApp.LoginVersion, - samlApp.LoginBaseURI, + gu.Value(samlApp.MetadataURL), + gu.Value(samlApp.LoginVersion), + gu.Value(samlApp.LoginBaseURI), ), }, nil } -func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { +func (c *Commands) UpdateSAMLApplication(ctx context.Context, samlApp *domain.SAMLApp, resourceOwner string) (*domain.SAMLApp, error) { if !samlApp.IsValid() || samlApp.AppID == "" || samlApp.AggregateID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-5n9fs", "Errors.Project.App.SAMLConfigInvalid") } @@ -100,10 +109,15 @@ func (c *Commands) ChangeSAMLApplication(ctx context.Context, samlApp *domain.SA if !existingSAML.IsSAML() { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-GBr35", "Errors.Project.App.IsNotSAML") } + + if err := c.checkPermissionUpdateApplication(ctx, existingSAML.ResourceOwner, existingSAML.AggregateID); err != nil { + return nil, err + } + projectAgg := ProjectAggregateFromWriteModel(&existingSAML.WriteModel) - if samlApp.MetadataURL != "" { - data, err := xml.ReadMetadataFromURL(c.httpClient, samlApp.MetadataURL) + if samlApp.MetadataURL != nil && *samlApp.MetadataURL != "" { + data, err := xml.ReadMetadataFromURL(c.httpClient, *samlApp.MetadataURL) if err != nil { return nil, zerrors.ThrowInvalidArgument(err, "SAML-J3kg3", "Errors.Project.App.SAMLMetadataMissing") } diff --git a/internal/command/project_application_saml_model.go b/internal/command/project_application_saml_model.go index f219039b58..f3097914f3 100644 --- a/internal/command/project_application_saml_model.go +++ b/internal/command/project_application_saml_model.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -170,26 +170,26 @@ func (wm *SAMLApplicationWriteModel) NewChangedEvent( appID string, entityID string, metadata []byte, - metadataURL string, - loginVersion domain.LoginVersion, - loginBaseURI string, + metadataURL *string, + loginVersion *domain.LoginVersion, + loginBaseURI *string, ) (*project.SAMLConfigChangedEvent, bool, error) { changes := make([]project.SAMLConfigChanges, 0) var err error - if !reflect.DeepEqual(wm.Metadata, metadata) { + if metadata != nil && !slices.Equal(wm.Metadata, metadata) { changes = append(changes, project.ChangeMetadata(metadata)) } - if wm.MetadataURL != metadataURL { - changes = append(changes, project.ChangeMetadataURL(metadataURL)) + if metadataURL != nil && wm.MetadataURL != *metadataURL { + changes = append(changes, project.ChangeMetadataURL(*metadataURL)) } if wm.EntityID != entityID { changes = append(changes, project.ChangeEntityID(entityID)) } - if wm.LoginVersion != loginVersion { - changes = append(changes, project.ChangeSAMLLoginVersion(loginVersion)) + if loginVersion != nil && wm.LoginVersion != *loginVersion { + changes = append(changes, project.ChangeSAMLLoginVersion(*loginVersion)) } - if wm.LoginBaseURI != loginBaseURI { - changes = append(changes, project.ChangeSAMLLoginBaseURI(loginBaseURI)) + if loginBaseURI != nil && wm.LoginBaseURI != *loginBaseURI { + changes = append(changes, project.ChangeSAMLLoginBaseURI(*loginBaseURI)) } if len(changes) == 0 { diff --git a/internal/command/project_application_saml_test.go b/internal/command/project_application_saml_test.go index c6f6f7cf21..5d18d9587c 100644 --- a/internal/command/project_application_saml_test.go +++ b/internal/command/project_application_saml_test.go @@ -7,6 +7,7 @@ import ( "net/http" "testing" + "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/api/authz" @@ -49,6 +50,8 @@ var testMetadataChangedEntityID = []byte(` `) func TestCommandSide_AddSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator @@ -117,6 +120,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), }, args: args{ @@ -134,6 +138,37 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, + { + name: "empty metas, invalid argument error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingUnspecified), + ), + ), + expectFilter(), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + samlApp: &domain.SAMLApp{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + AppName: "app", + EntityID: "https://test.com/saml/metadata", + }, + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, { name: "create saml app, metadata not parsable", fields: fields{ @@ -146,6 +181,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), }, @@ -158,7 +194,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: []byte("test metadata"), - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -178,6 +214,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -206,7 +243,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -216,12 +253,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -237,6 +276,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -265,9 +305,9 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -281,10 +321,10 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -300,6 +340,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), expectPush( project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -329,7 +370,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -339,12 +380,14 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test.com/saml/metadata", - Metadata: testMetadata, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test.com/saml/metadata", + Metadata: testMetadata, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, @@ -360,6 +403,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { domain.PrivateLabelingSettingUnspecified), ), ), + expectFilter(), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t), httpClient: newTestClient(http.StatusNotFound, nil), @@ -373,7 +417,7 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -385,10 +429,13 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + c := &Commands{ - eventstore: tt.fields.eventstore(t), - idGenerator: tt.fields.idGenerator, - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + idGenerator: tt.fields.idGenerator, + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } c.setMilestonesCompletedForTest("instanceID") got, err := c.AddSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) @@ -406,6 +453,8 @@ func TestCommandSide_AddSAMLApplication(t *testing.T) { } func TestCommandSide_ChangeSAMLApplication(t *testing.T) { + t.Parallel() + type fields struct { eventstore func(t *testing.T) *eventstore.Eventstore httpClient *http.Client @@ -544,7 +593,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -590,7 +639,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppID: "app1", EntityID: "https://test.com/saml/metadata", Metadata: testMetadata, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -646,7 +695,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: nil, - MetadataURL: "http://localhost:8080/saml/metadata", + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), }, resourceOwner: "org1", }, @@ -656,17 +705,19 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "http://localhost:8080/saml/metadata", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr("http://localhost:8080/saml/metadata"), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, }, { - name: "change saml app, ok, metadata", + name: "partial change saml app, ok, metadata", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -713,7 +764,7 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), }, resourceOwner: "org1", }, @@ -723,15 +774,18 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AggregateID: "project1", ResourceOwner: "org1", }, - AppID: "app1", - AppName: "app", - EntityID: "https://test2.com/saml/metadata", - Metadata: testMetadataChangedEntityID, - MetadataURL: "", - State: domain.AppStateActive, + AppID: "app1", + AppName: "app", + EntityID: "https://test2.com/saml/metadata", + Metadata: testMetadataChangedEntityID, + MetadataURL: gu.Ptr(""), + State: domain.AppStateActive, + LoginVersion: gu.Ptr(domain.LoginVersionUnspecified), + LoginBaseURI: gu.Ptr(""), }, }, - }, { + }, + { name: "change saml app, ok, loginversion", fields: fields{ eventstore: expectEventstore( @@ -781,9 +835,9 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + MetadataURL: gu.Ptr(""), + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, resourceOwner: "org1", }, @@ -797,10 +851,10 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { AppName: "app", EntityID: "https://test2.com/saml/metadata", Metadata: testMetadataChangedEntityID, - MetadataURL: "", + MetadataURL: gu.Ptr(""), State: domain.AppStateActive, - LoginVersion: domain.LoginVersion2, - LoginBaseURI: "https://test.com/login", + LoginVersion: gu.Ptr(domain.LoginVersion2), + LoginBaseURI: gu.Ptr("https://test.com/login"), }, }, }, @@ -808,11 +862,14 @@ func TestCommandSide_ChangeSAMLApplication(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore(t), - httpClient: tt.fields.httpClient, + eventstore: tt.fields.eventstore(t), + httpClient: tt.fields.httpClient, + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) + got, err := r.UpdateSAMLApplication(tt.args.ctx, tt.args.samlApp, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/project_application_test.go b/internal/command/project_application_test.go index 050a41d29f..a67e6886ed 100644 --- a/internal/command/project_application_test.go +++ b/internal/command/project_application_test.go @@ -8,13 +8,16 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/eventstore/repository/mock" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/zerrors" ) func TestCommandSide_ChangeApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -35,9 +38,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -55,9 +56,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -74,9 +73,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "invalid app missing name, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -94,10 +91,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -115,8 +109,7 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app name not changed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -142,8 +135,14 @@ func TestCommandSide_ChangeApplication(t *testing.T) { { name: "app changed, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -179,10 +178,13 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } - got, err := r.ChangeApplication(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) + got, err := r.UpdateApplicationName(tt.args.ctx, tt.args.projectID, tt.args.app, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } @@ -197,8 +199,10 @@ func TestCommandSide_ChangeApplication(t *testing.T) { } func TestCommandSide_DeactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -219,9 +223,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -236,9 +238,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -253,8 +253,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -271,8 +270,7 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app already inactive, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -299,8 +297,14 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { { name: "app deactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + )), + ), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -331,8 +335,11 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.DeactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -349,8 +356,10 @@ func TestCommandSide_DeactivateApplication(t *testing.T) { } func TestCommandSide_ReactivateApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -371,9 +380,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -388,9 +395,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -405,10 +410,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -423,8 +425,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app already active, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -447,8 +448,7 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { { name: "app reactivate, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -483,8 +483,11 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.ReactivateApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { @@ -501,8 +504,10 @@ func TestCommandSide_ReactivateApplication(t *testing.T) { } func TestCommandSide_RemoveApplication(t *testing.T) { + t.Parallel() + type fields struct { - eventstore *eventstore.Eventstore + eventstore func(*testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -523,9 +528,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing projectid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -540,9 +543,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "missing appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(func(mockRepository *mock.MockRepository) {}), }, args: args{ ctx: context.Background(), @@ -557,10 +558,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), }, args: args{ ctx: context.Background(), @@ -575,8 +573,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, entityID, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -584,6 +581,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { "app", )), ), + expectFilter(), expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -625,8 +623,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { { name: "app remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher(project.NewApplicationAddedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -636,6 +633,7 @@ func TestCommandSide_RemoveApplication(t *testing.T) { ), // app is not saml, or no saml config available expectFilter(), + expectFilter(), expectPush( project.NewApplicationRemovedEvent(context.Background(), &project.NewAggregate("project1", "org1").Aggregate, @@ -661,8 +659,11 @@ func TestCommandSide_RemoveApplication(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: newMockPermissionCheckAllowed(), } got, err := r.RemoveApplication(tt.args.ctx, tt.args.projectID, tt.args.appID, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/command/project_converter.go b/internal/command/project_converter.go index 01b5a4e63d..e88a1cb75a 100644 --- a/internal/command/project_converter.go +++ b/internal/command/project_converter.go @@ -1,6 +1,8 @@ package command import ( + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/domain" ) @@ -35,21 +37,21 @@ func oidcWriteModelToOIDCConfig(writeModel *OIDCApplicationWriteModel) *domain.O RedirectUris: writeModel.RedirectUris, ResponseTypes: writeModel.ResponseTypes, GrantTypes: writeModel.GrantTypes, - ApplicationType: writeModel.ApplicationType, - AuthMethodType: writeModel.AuthMethodType, + ApplicationType: gu.Ptr(writeModel.ApplicationType), + AuthMethodType: gu.Ptr(writeModel.AuthMethodType), PostLogoutRedirectUris: writeModel.PostLogoutRedirectUris, - OIDCVersion: writeModel.OIDCVersion, - DevMode: writeModel.DevMode, - AccessTokenType: writeModel.AccessTokenType, - AccessTokenRoleAssertion: writeModel.AccessTokenRoleAssertion, - IDTokenRoleAssertion: writeModel.IDTokenRoleAssertion, - IDTokenUserinfoAssertion: writeModel.IDTokenUserinfoAssertion, - ClockSkew: writeModel.ClockSkew, + OIDCVersion: gu.Ptr(writeModel.OIDCVersion), + DevMode: gu.Ptr(writeModel.DevMode), + AccessTokenType: gu.Ptr(writeModel.AccessTokenType), + AccessTokenRoleAssertion: gu.Ptr(writeModel.AccessTokenRoleAssertion), + IDTokenRoleAssertion: gu.Ptr(writeModel.IDTokenRoleAssertion), + IDTokenUserinfoAssertion: gu.Ptr(writeModel.IDTokenUserinfoAssertion), + ClockSkew: gu.Ptr(writeModel.ClockSkew), AdditionalOrigins: writeModel.AdditionalOrigins, - SkipNativeAppSuccessPage: writeModel.SkipNativeAppSuccessPage, - BackChannelLogoutURI: writeModel.BackChannelLogoutURI, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + SkipNativeAppSuccessPage: gu.Ptr(writeModel.SkipNativeAppSuccessPage), + BackChannelLogoutURI: gu.Ptr(writeModel.BackChannelLogoutURI), + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -60,10 +62,10 @@ func samlWriteModelToSAMLConfig(writeModel *SAMLApplicationWriteModel) *domain.S AppName: writeModel.AppName, State: writeModel.State, Metadata: writeModel.Metadata, - MetadataURL: writeModel.MetadataURL, + MetadataURL: gu.Ptr(writeModel.MetadataURL), EntityID: writeModel.EntityID, - LoginVersion: writeModel.LoginVersion, - LoginBaseURI: writeModel.LoginBaseURI, + LoginVersion: gu.Ptr(writeModel.LoginVersion), + LoginBaseURI: gu.Ptr(writeModel.LoginBaseURI), } } @@ -78,15 +80,6 @@ func apiWriteModelToAPIConfig(writeModel *APIApplicationWriteModel) *domain.APIA } } -func roleWriteModelToRole(writeModel *ProjectRoleWriteModel) *domain.ProjectRole { - return &domain.ProjectRole{ - ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), - Key: writeModel.Key, - DisplayName: writeModel.DisplayName, - Group: writeModel.Group, - } -} - func memberWriteModelToProjectGrantMember(writeModel *ProjectGrantMemberWriteModel) *domain.ProjectGrantMember { return &domain.ProjectGrantMember{ ObjectRoot: writeModelToObjectRoot(writeModel.WriteModel), diff --git a/internal/command/project_model.go b/internal/command/project_model.go index cabceb8500..4c9496b3ad 100644 --- a/internal/command/project_model.go +++ b/internal/command/project_model.go @@ -2,6 +2,7 @@ package command import ( "context" + "slices" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -120,7 +121,7 @@ func (wm *ProjectWriteModel) NewChangedEvent( } func isProjectStateExists(state domain.ProjectState) bool { - return !hasProjectState(state, domain.ProjectStateRemoved, domain.ProjectStateUnspecified) + return !slices.Contains([]domain.ProjectState{domain.ProjectStateRemoved, domain.ProjectStateUnspecified}, state) } func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { @@ -130,12 +131,3 @@ func ProjectAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggre func ProjectAggregateFromWriteModelWithCTX(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { return project.AggregateFromWriteModel(ctx, wm) } - -func hasProjectState(check domain.ProjectState, states ...domain.ProjectState) bool { - for _, state := range states { - if check == state { - return true - } - } - return false -} diff --git a/internal/domain/application_oidc.go b/internal/domain/application_oidc.go index 5d466c689d..10a70a1776 100644 --- a/internal/domain/application_oidc.go +++ b/internal/domain/application_oidc.go @@ -1,6 +1,7 @@ package domain import ( + "slices" "strings" "time" @@ -32,22 +33,22 @@ type OIDCApp struct { RedirectUris []string ResponseTypes []OIDCResponseType GrantTypes []OIDCGrantType - ApplicationType OIDCApplicationType - AuthMethodType OIDCAuthMethodType + ApplicationType *OIDCApplicationType + AuthMethodType *OIDCAuthMethodType PostLogoutRedirectUris []string - OIDCVersion OIDCVersion + OIDCVersion *OIDCVersion Compliance *Compliance - DevMode bool - AccessTokenType OIDCTokenType - AccessTokenRoleAssertion bool - IDTokenRoleAssertion bool - IDTokenUserinfoAssertion bool - ClockSkew time.Duration + DevMode *bool + AccessTokenType *OIDCTokenType + AccessTokenRoleAssertion *bool + IDTokenRoleAssertion *bool + IDTokenUserinfoAssertion *bool + ClockSkew *time.Duration AdditionalOrigins []string - SkipNativeAppSuccessPage bool - BackChannelLogoutURI string - LoginVersion LoginVersion - LoginBaseURI string + SkipNativeAppSuccessPage *bool + BackChannelLogoutURI *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -69,7 +70,7 @@ func (a *OIDCApp) setClientSecret(encodedHash string) { } func (a *OIDCApp) requiresClientSecret() bool { - return a.AuthMethodType == OIDCAuthMethodTypeBasic || a.AuthMethodType == OIDCAuthMethodTypePost + return a.AuthMethodType != nil && (*a.AuthMethodType == OIDCAuthMethodTypeBasic || *a.AuthMethodType == OIDCAuthMethodTypePost) } type OIDCVersion int32 @@ -137,7 +138,7 @@ const ( ) func (a *OIDCApp) IsValid() bool { - if a.ClockSkew > time.Second*5 || a.ClockSkew < time.Second*0 || !a.OriginsValid() { + if (a.ClockSkew != nil && (*a.ClockSkew > time.Second*5 || *a.ClockSkew < time.Second*0)) || !a.OriginsValid() { return false } grantTypes := a.getRequiredGrantTypes() @@ -204,30 +205,25 @@ func ContainsOIDCGrantTypes(shouldContain, list []OIDCGrantType) bool { } func containsOIDCGrantType(grantTypes []OIDCGrantType, grantType OIDCGrantType) bool { - for _, gt := range grantTypes { - if gt == grantType { - return true - } - } - return false + return slices.Contains(grantTypes, grantType) } func (a *OIDCApp) FillCompliance() { a.Compliance = GetOIDCCompliance(a.OIDCVersion, a.ApplicationType, a.GrantTypes, a.ResponseTypes, a.AuthMethodType, a.RedirectUris) } -func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { - switch version { - case OIDCVersionV1: +func GetOIDCCompliance(version *OIDCVersion, appType *OIDCApplicationType, grantTypes []OIDCGrantType, responseTypes []OIDCResponseType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { + if version != nil && *version == OIDCVersionV1 { return GetOIDCV1Compliance(appType, grantTypes, authMethod, redirectUris) } + return &Compliance{ NoneCompliant: true, Problems: []string{"Application.OIDC.UnsupportedVersion"}, } } -func GetOIDCV1Compliance(appType OIDCApplicationType, grantTypes []OIDCGrantType, authMethod OIDCAuthMethodType, redirectUris []string) *Compliance { +func GetOIDCV1Compliance(appType *OIDCApplicationType, grantTypes []OIDCGrantType, authMethod *OIDCAuthMethodType, redirectUris []string) *Compliance { compliance := &Compliance{NoneCompliant: false} checkGrantTypesCombination(compliance, grantTypes) @@ -247,7 +243,7 @@ func checkGrantTypesCombination(compliance *Compliance, grantTypes []OIDCGrantTy } } -func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType OIDCApplicationType, redirectUris []string) { +func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appType *OIDCApplicationType, redirectUris []string) { // See #5684 for OIDCGrantTypeDeviceCode and redirectUris further explanation if len(redirectUris) == 0 && (!containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) || (containsOIDCGrantType(grantTypes, OIDCGrantTypeDeviceCode) && containsOIDCGrantType(grantTypes, OIDCGrantTypeAuthorizationCode))) { compliance.NoneCompliant = true @@ -266,53 +262,58 @@ func checkRedirectURIs(compliance *Compliance, grantTypes []OIDCGrantType, appTy } } -func checkApplicationType(compliance *Compliance, appType OIDCApplicationType, authMethod OIDCAuthMethodType) { - switch appType { - case OIDCApplicationTypeNative: - GetOIDCV1NativeApplicationCompliance(compliance, authMethod) - case OIDCApplicationTypeUserAgent: - GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) +func checkApplicationType(compliance *Compliance, appType *OIDCApplicationType, authMethod *OIDCAuthMethodType) { + if appType != nil { + switch *appType { + case OIDCApplicationTypeNative: + GetOIDCV1NativeApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeUserAgent: + GetOIDCV1UserAgentApplicationCompliance(compliance, authMethod) + case OIDCApplicationTypeWeb: + return + } } + if compliance.NoneCompliant { compliance.Problems = append([]string{"Application.OIDC.V1.NotCompliant"}, compliance.Problems...) } } -func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1NativeApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.AuthMethodType.NotNone") } } -func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod OIDCAuthMethodType) { - if authMethod != OIDCAuthMethodTypeNone { +func GetOIDCV1UserAgentApplicationCompliance(compliance *Compliance, authMethod *OIDCAuthMethodType) { + if authMethod != nil && *authMethod != OIDCAuthMethodTypeNone { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.UserAgent.AuthMethodType.NotNone") } } -func CheckRedirectUrisCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { + if appType != nil && *appType == OIDCApplicationTypeNative && !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.CustomOnlyForNative") } } -func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicit(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } @@ -321,7 +322,7 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeNative { + if appType != nil && *appType == OIDCApplicationTypeNative { if !onlyLocalhostIsHttp(redirectUris) { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") @@ -333,20 +334,20 @@ func CheckRedirectUrisImplicit(compliance *Compliance, appType OIDCApplicationTy } } -func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType OIDCApplicationType, redirectUris []string) { +func CheckRedirectUrisImplicitAndCode(compliance *Compliance, appType *OIDCApplicationType, redirectUris []string) { if urlsAreHttps(redirectUris) { return } - if containsCustom(redirectUris) && appType != OIDCApplicationTypeNative { + if containsCustom(redirectUris) && appType != nil && *appType != OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed") } if urlContainsPrefix(redirectUris, http) { - if appType == OIDCApplicationTypeUserAgent { + if appType != nil && *appType == OIDCApplicationTypeUserAgent { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Code.RedirectUris.HttpOnlyForWeb") } - if !onlyLocalhostIsHttp(redirectUris) && appType == OIDCApplicationTypeNative { + if !onlyLocalhostIsHttp(redirectUris) && appType != nil && *appType == OIDCApplicationTypeNative { compliance.NoneCompliant = true compliance.Problems = append(compliance.Problems, "Application.OIDC.V1.Native.RedirectUris.MustBeHttpLocalhost") } diff --git a/internal/domain/application_oidc_test.go b/internal/domain/application_oidc_test.go index b3d9488827..4208917cdd 100644 --- a/internal/domain/application_oidc_test.go +++ b/internal/domain/application_oidc_test.go @@ -6,6 +6,8 @@ import ( "testing" "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" ) @@ -25,7 +27,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * 1, + ClockSkew: gu.Ptr(time.Minute * 1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -39,7 +41,7 @@ func TestApplicationValid(t *testing.T) { ObjectRoot: models.ObjectRoot{AggregateID: "AggregateID"}, AppID: "AppID", AppName: "AppName", - ClockSkew: time.Minute * -1, + ClockSkew: gu.Ptr(time.Minute * -1), ResponseTypes: []OIDCResponseType{OIDCResponseTypeCode}, GrantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, }, @@ -190,9 +192,9 @@ func TestApplicationValid(t *testing.T) { func TestGetOIDCV1Compliance(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType grantTypes []OIDCGrantType - authMethod OIDCAuthMethodType + authMethod *OIDCAuthMethodType redirectUris []string } tests := []struct { @@ -266,7 +268,7 @@ func Test_checkGrantTypesCombination(t *testing.T) { func Test_checkRedirectURIs(t *testing.T) { type args struct { grantTypes []OIDCGrantType - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -304,7 +306,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeImplicit}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, { @@ -316,7 +318,7 @@ func Test_checkRedirectURIs(t *testing.T) { args: args{ redirectUris: []string{"http://redirect.to/me"}, grantTypes: []OIDCGrantType{OIDCGrantTypeAuthorizationCode}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, }, } @@ -338,7 +340,7 @@ func Test_checkRedirectURIs(t *testing.T) { func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -356,17 +358,6 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { redirectUris: []string{"https://redirect.to/me"}, }, }, - // { - // name: "custom protocol, not native", - // want: &Compliance{ - // NoneCompliant: true, - // Problems: []string{"Application.OIDC.V1.Implicit.RedirectUris.CustomNotAllowed"}, - // }, - // args: args{ - // redirectUris: []string{"protocol://redirect.to/me"}, - // appType: OIDCApplicationTypeWeb, - // }, - // }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -386,7 +377,7 @@ func Test_CheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -402,7 +393,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "custom protocol not native app", args: args{ - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), redirectUris: []string{"custom://nirvana.com"}, }, want: &Compliance{ @@ -413,7 +404,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http localhost user agent app", args: args{ - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), redirectUris: []string{"http://localhost:9009"}, }, want: &Compliance{ @@ -424,7 +415,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "http, not only localhost native app", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"http://nirvana.com", "http://localhost:9009"}, }, want: &Compliance{ @@ -435,7 +426,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { { name: "not allowed combination", args: args{ - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), redirectUris: []string{"https://nirvana.com", "cutom://nirvana.com"}, }, want: &Compliance{ @@ -461,7 +452,7 @@ func TestCheckRedirectUrisImplicitAndCode(t *testing.T) { func TestCheckRedirectUrisImplicit(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -488,7 +479,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, not only localhost", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -499,7 +490,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: false, @@ -510,7 +501,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { name: "only http protocol, app type web", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -535,7 +526,7 @@ func TestCheckRedirectUrisImplicit(t *testing.T) { func TestCheckRedirectUrisCode(t *testing.T) { type args struct { - appType OIDCApplicationType + appType *OIDCApplicationType redirectUris []string } tests := []struct { @@ -552,7 +543,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom prefix, app type web", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, @@ -563,7 +554,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type user agent", args: args{ redirectUris: []string{"http://nirvana.com"}, - appType: OIDCApplicationTypeUserAgent, + appType: gu.Ptr(OIDCApplicationTypeUserAgent), }, want: &Compliance{ NoneCompliant: true, @@ -574,7 +565,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "only http protocol, app type native, only localhost", args: args{ redirectUris: []string{"http://localhost:8080", "http://nirvana.com:8080"}, - appType: OIDCApplicationTypeNative, + appType: gu.Ptr(OIDCApplicationTypeNative), }, want: &Compliance{ NoneCompliant: true, @@ -585,7 +576,7 @@ func TestCheckRedirectUrisCode(t *testing.T) { name: "custom protocol, not native", args: args{ redirectUris: []string{"custom://nirvana.com"}, - appType: OIDCApplicationTypeWeb, + appType: gu.Ptr(OIDCApplicationTypeWeb), }, want: &Compliance{ NoneCompliant: true, diff --git a/internal/domain/application_saml.go b/internal/domain/application_saml.go index de7ef789ee..aff1875c7e 100644 --- a/internal/domain/application_saml.go +++ b/internal/domain/application_saml.go @@ -11,9 +11,9 @@ type SAMLApp struct { AppName string EntityID string Metadata []byte - MetadataURL string - LoginVersion LoginVersion - LoginBaseURI string + MetadataURL *string + LoginVersion *LoginVersion + LoginBaseURI *string State AppState } @@ -31,11 +31,14 @@ func (a *SAMLApp) GetMetadata() []byte { } func (a *SAMLApp) GetMetadataURL() string { - return a.MetadataURL + if a.MetadataURL != nil { + return *a.MetadataURL + } + return "" } func (a *SAMLApp) IsValid() bool { - if a.MetadataURL == "" && a.Metadata == nil { + if (a.MetadataURL == nil || *a.MetadataURL == "") && a.Metadata == nil { return false } return true diff --git a/internal/domain/permission.go b/internal/domain/permission.go index bb569955f5..119e8c2d3e 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -47,6 +47,9 @@ const ( PermissionProjectRoleWrite = "project.role.write" PermissionProjectRoleRead = "project.role.read" PermissionProjectRoleDelete = "project.role.delete" + PermissionProjectAppWrite = "project.app.write" + PermissionProjectAppDelete = "project.app.delete" + PermissionProjectAppRead = "project.app.read" ) // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. diff --git a/internal/integration/client.go b/internal/integration/client.go index 20c98b5628..326d6fa8b4 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -22,6 +22,7 @@ import ( "github.com/zitadel/zitadel/internal/integration/scim" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" "github.com/zitadel/zitadel/pkg/grpc/admin" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" "github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" @@ -75,6 +76,7 @@ type Client struct { SCIM *scim.Client Projectv2Beta project_v2beta.ProjectServiceClient InstanceV2Beta instance.InstanceServiceClient + AppV2Beta app.AppServiceClient } func NewDefaultClient(ctx context.Context) (*Client, error) { @@ -114,6 +116,7 @@ func newClient(ctx context.Context, target string) (*Client, error) { SCIM: scim.NewScimClient(target), Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), InstanceV2Beta: instance.NewInstanceServiceClient(cc), + AppV2Beta: app.NewAppServiceClient(cc), } return client, client.pollHealth(ctx) } diff --git a/internal/project/model/oidc_config.go b/internal/project/model/oidc_config.go index 50be6c318a..2c482a67a7 100644 --- a/internal/project/model/oidc_config.go +++ b/internal/project/model/oidc_config.go @@ -3,6 +3,8 @@ package model import ( "time" + "github.com/muhlemmer/gu" + "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/domain" es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -98,7 +100,7 @@ func GetOIDCCompliance(version OIDCVersion, appType OIDCApplicationType, grantTy for i, grantType := range grantTypes { domainGrantTypes[i] = domain.OIDCGrantType(grantType) } - compliance := domain.GetOIDCV1Compliance(domain.OIDCApplicationType(appType), domainGrantTypes, domain.OIDCAuthMethodType(authMethod), redirectUris) + compliance := domain.GetOIDCV1Compliance(gu.Ptr(domain.OIDCApplicationType(appType)), domainGrantTypes, gu.Ptr(domain.OIDCAuthMethodType(authMethod)), redirectUris) return &Compliance{ NoneCompliant: compliance.NoneCompliant, Problems: compliance.Problems, diff --git a/internal/query/app.go b/internal/query/app.go index bc97c1807e..777c295139 100644 --- a/internal/query/app.go +++ b/internal/query/app.go @@ -5,9 +5,11 @@ import ( "database/sql" _ "embed" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" + "github.com/muhlemmer/gu" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" @@ -307,6 +309,19 @@ func (q *Queries) AppByProjectAndAppID(ctx context.Context, shouldTriggerBulk bo return app, err } +func (q *Queries) AppByIDWithPermission(ctx context.Context, appID string, activeOnly bool, permissionCheck domain.PermissionCheck) (*App, error) { + app, err := q.AppByID(ctx, appID, activeOnly) + if err != nil { + return nil, err + } + + if err := appCheckPermission(ctx, app.ResourceOwner, app.ProjectID, permissionCheck); err != nil { + return nil, err + } + + return app, nil +} + func (q *Queries) AppByID(ctx context.Context, appID string, activeOnly bool) (app *App, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -476,11 +491,54 @@ func (q *Queries) AppByOIDCClientID(ctx context.Context, clientID string) (app * return app, err } -func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, withOwnerRemoved bool) (apps *Apps, err error) { +func (q *Queries) AppByClientID(ctx context.Context, clientID string) (app *App, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + stmt, scan := prepareAppQuery(true) + eq := sq.Eq{ + AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID(), + AppColumnState.identifier(): domain.AppStateActive, + ProjectColumnState.identifier(): domain.ProjectStateActive, + OrgColumnState.identifier(): domain.OrgStateActive, + } + query, args, err := stmt.Where(sq.And{ + eq, + sq.Or{ + sq.Eq{AppOIDCConfigColumnClientID.identifier(): clientID}, + sq.Eq{AppAPIConfigColumnClientID.identifier(): clientID}, + }, + }).ToSql() + if err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-Dfge2", "Errors.Query.SQLStatement") + } + + err = q.client.QueryRowContext(ctx, func(row *sql.Row) error { + app, err = scan(row) + return err + }, query, args...) + return app, err +} + +func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, permissionCheck domain.PermissionCheck) (*Apps, error) { + apps, err := q.searchApps(ctx, queries, PermissionV2(ctx, permissionCheck)) + if err != nil { + return nil, err + } + + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + apps.Apps = appsCheckPermission(ctx, apps.Apps, permissionCheck) + } + return apps, nil +} + +func (q *Queries) searchApps(ctx context.Context, queries *AppSearchQueries, isPermissionV2Enabled bool) (apps *Apps, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() query, scan := prepareAppsQuery() + query = appPermissionCheckV2(ctx, query, isPermissionV2Enabled, queries) + eq := sq.Eq{AppColumnInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -498,6 +556,21 @@ func (q *Queries) SearchApps(ctx context.Context, queries *AppSearchQueries, wit return apps, err } +func appPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *AppSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + + join, args := PermissionClause( + ctx, + AppColumnResourceOwner, + domain.PermissionProjectAppRead, + SingleOrgPermissionOption(queries.Queries), + WithProjectsPermissionOption(AppColumnProjectID), + ) + return query.JoinClause(join, args...) +} + func (q *Queries) SearchClientIDs(ctx context.Context, queries *AppSearchQueries, shouldTriggerBulk bool) (ids []string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -574,10 +647,25 @@ func (q *Queries) SAMLAppLoginVersion(ctx context.Context, appID string) (loginV return loginVersion, nil } +func appCheckPermission(ctx context.Context, resourceOwner string, projectID string, permissionCheck domain.PermissionCheck) error { + return permissionCheck(ctx, domain.PermissionProjectAppRead, resourceOwner, projectID) +} + +// appsCheckPermission returns only the apps that the user in context has permission to read +func appsCheckPermission(ctx context.Context, apps []*App, permissionCheck domain.PermissionCheck) []*App { + return slices.DeleteFunc(apps, func(app *App) bool { + return permissionCheck(ctx, domain.PermissionProjectAppRead, app.ResourceOwner, app.ProjectID) != nil + }) +} + func NewAppNameSearchQuery(method TextComparison, value string) (SearchQuery, error) { return NewTextQuery(AppColumnName, value, method) } +func NewAppStateSearchQuery(value domain.AppState) (SearchQuery, error) { + return NewNumberQuery(AppColumnState, int(value), NumberEquals) +} + func NewAppProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(AppColumnProjectID, id, TextEquals) } @@ -1089,7 +1177,7 @@ func (c sqlOIDCConfig) set(app *App) { if c.loginBaseURI.Valid { app.OIDCConfig.LoginBaseURI = &c.loginBaseURI.String } - compliance := domain.GetOIDCCompliance(app.OIDCConfig.Version, app.OIDCConfig.AppType, app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, app.OIDCConfig.AuthMethodType, app.OIDCConfig.RedirectURIs) + compliance := domain.GetOIDCCompliance(gu.Ptr(app.OIDCConfig.Version), gu.Ptr(app.OIDCConfig.AppType), app.OIDCConfig.GrantTypes, app.OIDCConfig.ResponseTypes, gu.Ptr(app.OIDCConfig.AuthMethodType), app.OIDCConfig.RedirectURIs) app.OIDCConfig.ComplianceProblems = compliance.Problems var err error diff --git a/pkg/grpc/app/v2beta/application.go b/pkg/grpc/app/v2beta/application.go new file mode 100644 index 0000000000..bbce4289f9 --- /dev/null +++ b/pkg/grpc/app/v2beta/application.go @@ -0,0 +1,5 @@ +package app + +type ApplicationConfig = isApplication_Config + +type MetaType = isUpdateSAMLApplicationConfigurationRequest_Metadata \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/api.proto b/proto/zitadel/app/v2beta/api.proto new file mode 100644 index 0000000000..9ef09d5ad8 --- /dev/null +++ b/proto/zitadel/app/v2beta/api.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +enum APIAuthMethodType { + API_AUTH_METHOD_TYPE_BASIC = 0; + API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 1; +} + +message APIConfig { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client_id"; + } + ]; + APIAuthMethodType auth_method_type = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the API passes the login credentials"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/app.proto b/proto/zitadel/app/v2beta/app.proto new file mode 100644 index 0000000000..f85e3c021d --- /dev/null +++ b/proto/zitadel/app/v2beta/app.proto @@ -0,0 +1,94 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/saml.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/filter/v2/filter.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message Application { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + AppState state = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the application"; + } + ]; + string name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Console\""; + } + ]; + oneof config { + OIDCConfig oidc_config = 6; + APIConfig api_config = 7; + SAMLConfig saml_config = 8; + } +} + +enum AppState { + APP_STATE_UNSPECIFIED = 0; + APP_STATE_ACTIVE = 1; + APP_STATE_INACTIVE = 2; + APP_STATE_REMOVED = 3; +} + +enum AppSorting { + APP_SORT_BY_ID = 0; + APP_SORT_BY_NAME = 1; + APP_SORT_BY_STATE = 2; + APP_SORT_BY_CREATION_DATE = 3; + APP_SORT_BY_CHANGE_DATE = 4; +} + +message ApplicationSearchFilter { + oneof filter { + option (validate.required) = true; + ApplicationNameQuery name_filter = 1; + AppState state_filter = 2; + bool api_app_only = 3; + bool oidc_app_only = 4; + bool saml_app_only = 5; + } +} + +message ApplicationNameQuery { + string name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"Conso\"" + } + ]; + + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used" + } + ]; +} diff --git a/proto/zitadel/app/v2beta/app_service.proto b/proto/zitadel/app/v2beta/app_service.proto new file mode 100644 index 0000000000..a881022caa --- /dev/null +++ b/proto/zitadel/app/v2beta/app_service.proto @@ -0,0 +1,788 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/app/v2beta/login.proto"; +import "zitadel/app/v2beta/oidc.proto"; +import "zitadel/app/v2beta/api.proto"; +import "zitadel/app/v2beta/app.proto"; +import "google/protobuf/timestamp.proto"; +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/filter/v2/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Application Service"; + version: "2.0-beta"; + description: "This API is intended to manage apps (SAML, OIDC, etc..) in a ZITADEL instance. This service is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + +// Service to manage apps. +// The service provides methods to create, update, delete and list apps and app keys. +service AppService { + + // Create Application + // + // Create an application. The application can be OIDC, API or SAML type, based on the input. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc CreateApplication(CreateApplicationRequest) returns (CreateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The created application"; + } + }; + }; + + option (google.api.http) = { + post: "/v2beta/applications" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Update Application + // + // Changes the configuration of an OIDC, API or SAML type application, as well as + // the application name, based on the input provided. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc UpdateApplication(UpdateApplicationRequest) returns (UpdateApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The updated app."; + } + }; + }; + + option (google.api.http) = { + patch: "/v2beta/applications/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Get Application + // + // Retrieves the application matching the provided ID. + // + // The user needs to have project.app.read permission + // + // Required permissions: + // - project.app.read + rpc GetApplication(GetApplicationRequest) returns (GetApplicationResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The fetched app."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/applications/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Delete Application + // + // Deletes the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.delete permission + // + // Required permissions: + // - project.app.delete + rpc DeleteApplication(DeleteApplicationRequest) returns (DeleteApplicationResponse) { + option (google.api.http) = { + delete: "/v2beta/applications/{id}" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deletion."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Deactivate Application + // + // Deactivates the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc DeactivateApplication(DeactivateApplicationRequest) returns (DeactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/deactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // Reactivate Application + // + // Reactivates the application belonging to the input project and matching the provided + // application ID + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc ReactivateApplication(ReactivateApplicationRequest) returns (ReactivateApplicationResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{id}/reactivate" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of reactivation."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + + // Regenerate Client Secret + // + // Regenerates the client secret of an API or OIDC application that belongs to the input project. + // + // The user needs to have project.app.write permission + // + // Required permissions: + // - project.app.write + rpc RegenerateClientSecret(RegenerateClientSecretRequest) returns (RegenerateClientSecretResponse) { + option (google.api.http) = { + post: "/v2beta/applications/{application_id}/generate_client_secret" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The regenerated client secret."; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Applications + // + // Returns a list of applications matching the input parameters that belong to the provided + // project. + // + // The result can be sorted by app id, name, creation date, change date or state. It can also + // be filtered by app state, app type and app name. + // + // The user needs to have project.app.read permission + // + // Required permissions: + // - project.app.read + rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse) { + option (google.api.http) = { + post: "/v2beta/applications/search" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The matching applications"; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } +} + +message CreateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {max_len: 200}]; + string name = 3 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"MyApp\""; + } + ]; + oneof creation_request_type { + option (validate.required) = true; + CreateOIDCApplicationRequest oidc_request = 4; + CreateSAMLApplicationRequest saml_request = 5; + CreateAPIApplicationRequest api_request = 6; + } +} + +message CreateApplicationResponse { + string app_id = 1; + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + oneof creation_response_type { + CreateOIDCApplicationResponse oidc_response = 3; + CreateSAMLApplicationResponse saml_response = 4; + CreateAPIApplicationResponse api_response = 5; + } +} + +message CreateOIDCApplicationRequest { + // Callback URI of the authorization request where the code or tokens will be sent to + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + + // ZITADEL will redirect to this link after a successful logout + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateOIDCApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"1035496534033449\""; + description: "generated client id for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; + bool none_compliant = 3; + repeated OIDCLocalizedMessage compliance_problems = 4; +} + +message CreateSAMLApplicationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message CreateSAMLApplicationResponse {} + +message CreateAPIApplicationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message CreateAPIApplicationResponse { + string client_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"3950723409029374\""; + description: "generated secret for this config"; + } + ]; + string client_secret = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for this config"; + } + ]; +} + +message UpdateApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; + string name = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"MyApplicationName\""; + min_length: 1; + max_length: 200; + } + ]; + + oneof update_request_type { + UpdateSAMLApplicationConfigurationRequest saml_configuration_request = 4; + UpdateOIDCApplicationConfigurationRequest oidc_configuration_request = 5; + UpdateAPIApplicationConfigurationRequest api_configuration_request = 6; + } +} + +message UpdateApplicationResponse { + // The timestamp of the app update. + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message UpdateSAMLApplicationConfigurationRequest { + oneof metadata { + option (validate.required) = true; + bytes metadata_xml = 1 [(validate.rules).bytes.max_len = 500000]; + string metadata_url = 2 [(validate.rules).string.max_len = 200]; + } + optional LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateOIDCApplicationConfigurationRequest { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + optional OIDCAppType app_type = 4 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines the paradigm of the application"; + } + ]; + optional OIDCAuthMethodType auth_method_type = 5 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"http://localhost:4200/signedout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + optional OIDCVersion version = 7 [(validate.rules).enum = {defined_only: true}]; + optional bool dev_mode = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used for development, some checks of the OIDC specification will not be checked."; + } + ]; + optional OIDCTokenType access_token_type = 9 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of the access token returned from ZITADEL"; + } + ]; + optional bool access_token_role_assertion = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + optional bool id_token_role_assertion = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + optional bool id_token_userinfo_assertion = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + optional google.protobuf.Duration clock_skew = 13 [ + (validate.rules).duration = {gte: {}, lte: {seconds: 5}}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + example: "\"1s\""; + } + ]; + repeated string additional_origins = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"scheme://localhost:8080\"]"; + description: "Additional origins (other than the redirect_uris) from where the API can be used, provided string has to be an origin (scheme://hostname[:port]) without path, query or fragment"; + } + ]; + optional bool skip_native_app_success_page = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + optional string back_channel_logout_uri = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + optional LoginVersion login_version = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} + +message UpdateAPIApplicationConfigurationRequest { + APIAuthMethodType auth_method_type = 1 [(validate.rules).enum = {defined_only: true}]; +} + +message GetApplicationRequest { + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"45984352431\""; + } + ]; +} + +message GetApplicationResponse { + Application app = 1; +} + +message DeleteApplicationRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeleteApplicationResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message DeactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message DeactivateApplicationResponse{ + google.protobuf.Timestamp deactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ReactivateApplicationRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message ReactivateApplicationResponse{ + google.protobuf.Timestamp reactivation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message RegenerateClientSecretRequest{ + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string application_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + oneof app_type { + option (validate.required) = true; + bool is_oidc = 3; + bool is_api = 4; + } +} + +message RegenerateClientSecretResponse{ + string client_secret = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gjoq34589uasgh\""; + description: "generated secret for the client"; + } + ]; + + // The timestamp of the creation of the new client secret + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListApplicationsRequest { + string project_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; + + //criteria the client is looking for + repeated ApplicationSearchFilter filters = 3; + + AppSorting sorting_column = 4; +} + +message ListApplicationsResponse { + repeated Application applications = 1; + + // Contains the total number of apps matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/login.proto b/proto/zitadel/app/v2beta/login.proto new file mode 100644 index 0000000000..567b4b5167 --- /dev/null +++ b/proto/zitadel/app/v2beta/login.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message LoginVersion { + oneof version { + LoginV1 login_v1 = 1; + LoginV2 login_v2 = 2; + } +} + +message LoginV1 {} + +message LoginV2 { + // Optionally specify a base uri of the login UI. If unspecified the default URI will be used. + optional string base_uri = 1; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/oidc.proto b/proto/zitadel/app/v2beta/oidc.proto new file mode 100644 index 0000000000..7cfd1dcc43 --- /dev/null +++ b/proto/zitadel/app/v2beta/oidc.proto @@ -0,0 +1,166 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/duration.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message OIDCLocalizedMessage { + string key = 1; + string localized_message = 2; +} + +enum OIDCResponseType { + OIDC_RESPONSE_TYPE_UNSPECIFIED = 0; + OIDC_RESPONSE_TYPE_CODE = 1; + OIDC_RESPONSE_TYPE_ID_TOKEN = 2; + OIDC_RESPONSE_TYPE_ID_TOKEN_TOKEN = 3; +} + +enum OIDCGrantType{ + OIDC_GRANT_TYPE_AUTHORIZATION_CODE = 0; + OIDC_GRANT_TYPE_IMPLICIT = 1; + OIDC_GRANT_TYPE_REFRESH_TOKEN = 2; + OIDC_GRANT_TYPE_DEVICE_CODE = 3; + OIDC_GRANT_TYPE_TOKEN_EXCHANGE = 4; +} + +enum OIDCAppType { + OIDC_APP_TYPE_WEB = 0; + OIDC_APP_TYPE_USER_AGENT = 1; + OIDC_APP_TYPE_NATIVE = 2; +} + +enum OIDCAuthMethodType { + OIDC_AUTH_METHOD_TYPE_BASIC = 0; + OIDC_AUTH_METHOD_TYPE_POST = 1; + OIDC_AUTH_METHOD_TYPE_NONE = 2; + OIDC_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT = 3; +} + +enum OIDCVersion { + OIDC_VERSION_1_0 = 0; +} + +enum OIDCTokenType { + OIDC_TOKEN_TYPE_BEARER = 0; + OIDC_TOKEN_TYPE_JWT = 1; +} + +message OIDCConfig { + repeated string redirect_uris = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "Callback URI of the authorization request where the code or tokens will be sent to"; + } + ]; + repeated OIDCResponseType response_types = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Determines whether a code, id_token token or just id_token will be returned" + } + ]; + repeated OIDCGrantType grant_types = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "The flow type the application uses to gain access"; + } + ]; + OIDCAppType app_type = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "determines the paradigm of the application"; + } + ]; + string client_id = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334@ZITADEL\""; + description: "generated oauth2/oidc client id"; + } + ]; + OIDCAuthMethodType auth_method_type = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines how the application passes login credentials"; + } + ]; + repeated string post_logout_redirect_uris = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/logout\"]"; + description: "ZITADEL will redirect to this link after a successful logout"; + } + ]; + OIDCVersion version = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the OIDC version used by the application"; + } + ]; + bool none_compliant = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "specifies whether the config is OIDC compliant. A production configuration SHOULD be compliant"; + } + ]; + repeated OIDCLocalizedMessage compliance_problems = 10 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "lists the problems for non-compliancy"; + } + ]; + bool dev_mode = 11 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "used for development"; + } + ]; + OIDCTokenType access_token_type = 12 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "type of the access token returned from ZITADEL"; + } + ]; + bool access_token_role_assertion = 13 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the access token (only if type == JWT) even if they are not requested by scopes"; + } + ]; + bool id_token_role_assertion = 14 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "adds roles to the claims of the id token even if they are not requested by scopes"; + } + ]; + bool id_token_userinfo_assertion = 15 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "claims of profile, email, address and phone scopes are added to the id token even if an access token is issued. Attention this violates the OIDC specification"; + } + ]; + google.protobuf.Duration clock_skew = 16 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Used to compensate time difference of servers. Duration added to the \"exp\" claim and subtracted from \"iat\", \"auth_time\" and \"nbf\" claims"; + // min: "0s"; + // max: "5s"; + } + ]; + repeated string additional_origins = 17 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "additional origins (other than the redirect_uris) from where the API can be used"; + } + ]; + repeated string allowed_origins = 18 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://console.zitadel.ch/auth/callback\"]"; + description: "all allowed origins from where the API can be used"; + } + ]; + bool skip_native_app_success_page = 19 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Skip the successful login page on native apps and directly redirect the user to the callback."; + } + ]; + string back_channel_logout_uri = 20 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"https://example.com/auth/backchannel\"]"; + description: "ZITADEL will use this URI to notify the application about terminated session according to the OIDC Back-Channel Logout (https://openid.net/specs/openid-connect-backchannel-1_0.html)"; + } + ]; + LoginVersion login_version = 21 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/saml.proto b/proto/zitadel/app/v2beta/saml.proto new file mode 100644 index 0000000000..7c85447880 --- /dev/null +++ b/proto/zitadel/app/v2beta/saml.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package zitadel.app.v2beta; + +import "zitadel/app/v2beta/login.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/app/v2beta;app"; + +message SAMLConfig { + oneof metadata{ + bytes metadata_xml = 1; + string metadata_url = 2; + } + LoginVersion login_version = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Specify the preferred login UI, where the user is redirected to for authentication. If unset, the login UI is chosen by the instance default."; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index d633fbe8c5..74d5dcf60b 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -3287,6 +3287,7 @@ service ManagementService { }; } + // Deprecated: Use [GetApplication](/apis/resources/application_service_v2/application-service-get-application.api.mdx) instead to fetch an app rpc GetAppByID(GetAppByIDRequest) returns (GetAppByIDResponse) { option (google.api.http) = { get: "/projects/{project_id}/apps/{app_id}" @@ -3309,9 +3310,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ListApplications](/apis/resources/application_service_v2/application-service-list-applications.api.mdx) instead to list applications rpc ListApps(ListAppsRequest) returns (ListAppsResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/_search" @@ -3335,6 +3338,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } @@ -3363,6 +3367,7 @@ service ManagementService { }; } + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an OIDC application rpc AddOIDCApp(AddOIDCAppRequest) returns (AddOIDCAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/oidc" @@ -3386,62 +3391,74 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } - rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/saml" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (SAML)"; - description: "Create a new SAML client. Returns an entity ID" - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { - option (google.api.http) = { - post: "/projects/{project_id}/apps/api" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create a SAML application + rpc AddSAMLApp(AddSAMLAppRequest) returns (AddSAMLAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/saml" + body: "*" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Create Application (API)"; - description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (SAML)"; + description: "Create a new SAML client. Returns an entity ID" + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Create Application (API) + // + // Create a new API client. The client id will be generated and returned in the response. + // Depending on the chosen configuration also a secret will be generated and returned. + // + // Deprecated: Use [CreateApplication](/apis/resources/application_service_v2/application-service-create-application.api.mdx) instead to create an API application + rpc AddAPIApp(AddAPIAppRequest) returns (AddAPIAppResponse) { + option (google.api.http) = { + post: "/projects/{project_id}/apps/api" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Create Application (API)"; + description: "Create a new API client. The client id will be generated and returned in the response. Depending on the chosen configuration also a secret will be generated and returned." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } // Changes application + // + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the generic params of an app rpc UpdateApp(UpdateAppRequest) returns (UpdateAppResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}" @@ -3465,9 +3482,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an OIDC app rpc UpdateOIDCAppConfig(UpdateOIDCAppConfigRequest) returns (UpdateOIDCAppConfigResponse) { option (google.api.http) = { put: "/projects/{project_id}/apps/{app_id}/oidc_config" @@ -3491,61 +3510,67 @@ service ManagementService { required: false; }; }; + deprecated: true }; } - rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/saml_config" - body: "*" - }; - - option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" - }; - - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update SAML Application Config"; - description: "Update the SAML specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; - } - - rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { - option (google.api.http) = { - put: "/projects/{project_id}/apps/{app_id}/api_config" - body: "*" - }; + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of a SAML app + rpc UpdateSAMLAppConfig(UpdateSAMLAppConfigRequest) returns (UpdateSAMLAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/saml_config" + body: "*" + }; option (zitadel.v1.auth_option) = { - permission: "project.app.write" - check_field_name: "ProjectId" + permission: "project.app.write" + check_field_name: "ProjectId" }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "Applications"; - summary: "Update API Application Config"; - description: "Update the OIDC-specific configuration of an application." - parameters: { - headers: { - name: "x-zitadel-orgid"; - description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; - type: STRING, - required: false; - }; - }; - }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update SAML Application Config"; + description: "Update the SAML specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; } + // Deprecated: Use [PatchApplication](/apis/resources/application_service_v2/application-service-patch-application.api.mdx) instead to update the config of an API app + rpc UpdateAPIAppConfig(UpdateAPIAppConfigRequest) returns (UpdateAPIAppConfigResponse) { + option (google.api.http) = { + put: "/projects/{project_id}/apps/{app_id}/api_config" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "project.app.write" + check_field_name: "ProjectId" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Applications"; + summary: "Update API Application Config"; + description: "Update the OIDC-specific configuration of an application." + parameters: { + headers: { + name: "x-zitadel-orgid"; + description: "The default is always the organization of the requesting user. If you like to change/get objects of another organization include the header. Make sure the requesting user has permission to access the requested data."; + type: STRING, + required: false; + }; + }; + deprecated: true; + }; + } + + // Deprecated: Use [DeactivateApplication](/apis/resources/application_service_v2/application-service-deactivate-application.api.mdx) instead to deactivate an app rpc DeactivateApp(DeactivateAppRequest) returns (DeactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_deactivate" @@ -3569,9 +3594,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ReactivateApplication](/apis/resources/application_service_v2/application-service-reactivate-application.api.mdx) instead to reactivate an app rpc ReactivateApp(ReactivateAppRequest) returns (ReactivateAppResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/_reactivate" @@ -3595,9 +3622,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [DeleteApplication](/apis/resources/application_service_v2/application-service-delete-application.api.mdx) instead to delete an app rpc RemoveApp(RemoveAppRequest) returns (RemoveAppResponse) { option (google.api.http) = { delete: "/projects/{project_id}/apps/{app_id}" @@ -3620,9 +3649,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an OIDC app client secret rpc RegenerateOIDCClientSecret(RegenerateOIDCClientSecretRequest) returns (RegenerateOIDCClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/oidc_config/_generate_client_secret" @@ -3646,9 +3677,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [RegenerateClientSecret](/apis/resources/application_service_v2/application-service-regenerate-client-secret.api.mdx) instead to regenerate an API app client secret rpc RegenerateAPIClientSecret(RegenerateAPIClientSecretRequest) returns (RegenerateAPIClientSecretResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/api_config/_generate_client_secret" @@ -3672,6 +3705,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } From 14b45b58ebc955f7ee2b1161b74bd2f57ed07916 Mon Sep 17 00:00:00 2001 From: Florian Forster Date: Fri, 27 Jun 2025 13:46:21 -0700 Subject: [PATCH 108/123] chore: add inkeep search and ai to docs (#10119) --- docs/docusaurus.config.js | 111 ++- docs/package.json | 1 + docs/yarn.lock | 1776 ++++++++++++++++++++++++++++++------- 3 files changed, 1508 insertions(+), 380 deletions(-) diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 43830eafd0..abf5c742a5 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -71,13 +71,13 @@ module.exports = { label: "🚀 Quick Start", docId: "guides/start/quickstart", position: "left", - }, + }, { type: "doc", label: "Documentation", docId: "guides/overview", position: "left", - }, + }, { type: "doc", label: "APIs", @@ -174,20 +174,25 @@ module.exports = { { label: "Status", href: "https://status.zitadel.com/", - } + }, ], }, ], copyright: `Copyright © ${new Date().getFullYear()} ZITADEL Docs - Built with Docusaurus.`, }, - algolia: { - appId: "8H6ZKXENLO", - apiKey: "124fe1c102a184bc6fc70c75dc84f96f", - indexName: "zitadel", - selector: "div#", - }, prism: { - additionalLanguages: ["csharp", "dart", "groovy", "regex", "java", "php", "python", "protobuf", "json", "bash"], + additionalLanguages: [ + "csharp", + "dart", + "groovy", + "regex", + "java", + "php", + "python", + "protobuf", + "json", + "bash", + ], }, colorMode: { defaultMode: "dark", @@ -196,9 +201,9 @@ module.exports = { }, codeblock: { showGithubLink: true, - githubLinkLabel: 'View on GitHub', + githubLinkLabel: "View on GitHub", showRunmeLink: false, - runmeLinkLabel: 'Checkout via Runme' + runmeLinkLabel: "Checkout via Runme", }, }, presets: [ @@ -213,19 +218,33 @@ module.exports = { showLastUpdateTime: true, editUrl: "https://github.com/zitadel/zitadel/edit/main/docs/", remarkPlugins: [require("mdx-mermaid")], - - docItemComponent: '@theme/ApiItem' + + docItemComponent: "@theme/ApiItem", }, theme: { customCss: require.resolve("./src/css/custom.css"), }, - }) + }), ], - ], plugins: [ [ - 'docusaurus-plugin-openapi-docs', + "@inkeep/cxkit-docusaurus", + { + SearchBar: { + baseSettings: { + apiKey: process.env.INKEEP_API_KEY, + primaryBrandColor: "#ff2069", + organizationDisplayName: "ZITADEL", + }, + }, + SearchSettings: { + tabs: ["All", "Docs", "GitHub", "Forums", "Discord"], + }, + }, + ], + [ + "docusaurus-plugin-openapi-docs", { id: "apiDocs", docsPluginId: "classic", @@ -263,7 +282,8 @@ module.exports = { }, }, user_v2: { - specPath: ".artifacts/openapi/zitadel/user/v2/user_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/user/v2/user_service.swagger.json", outputDir: "docs/apis/resources/user_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -271,7 +291,8 @@ module.exports = { }, }, session_v2: { - specPath: ".artifacts/openapi/zitadel/session/v2/session_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/session/v2/session_service.swagger.json", outputDir: "docs/apis/resources/session_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -279,7 +300,8 @@ module.exports = { }, }, oidc_v2: { - specPath: ".artifacts/openapi/zitadel/oidc/v2/oidc_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/oidc/v2/oidc_service.swagger.json", outputDir: "docs/apis/resources/oidc_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -287,7 +309,8 @@ module.exports = { }, }, saml_v2: { - specPath: ".artifacts/openapi/zitadel/saml/v2/saml_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/saml/v2/saml_service.swagger.json", outputDir: "docs/apis/resources/saml_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -295,7 +318,8 @@ module.exports = { }, }, settings_v2: { - specPath: ".artifacts/openapi/zitadel/settings/v2/settings_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/settings/v2/settings_service.swagger.json", outputDir: "docs/apis/resources/settings_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -303,31 +327,35 @@ module.exports = { }, }, action_v2: { - specPath: ".artifacts/openapi/zitadel/action/v2beta/action_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/action/v2beta/action_service.swagger.json", outputDir: "docs/apis/resources/action_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, webkey_v2: { - specPath: ".artifacts/openapi/zitadel/webkey/v2beta/webkey_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/webkey/v2beta/webkey_service.swagger.json", outputDir: "docs/apis/resources/webkey_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, feature_v2: { - specPath: ".artifacts/openapi/zitadel/feature/v2/feature_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/feature/v2/feature_service.swagger.json", outputDir: "docs/apis/resources/feature_service_v2", sidebarOptions: { - groupPathsBy: "tag", - categoryLinkSource: "auto", + groupPathsBy: "tag", + categoryLinkSource: "auto", }, }, org_v2: { - specPath: ".artifacts/openapi/zitadel/org/v2/org_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/org/v2/org_service.swagger.json", outputDir: "docs/apis/resources/org_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -335,7 +363,8 @@ module.exports = { }, }, idp_v2: { - specPath: ".artifacts/openapi/zitadel/idp/v2/idp_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/idp/v2/idp_service.swagger.json", outputDir: "docs/apis/resources/idp_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -343,7 +372,8 @@ module.exports = { }, }, org_v2beta: { - specPath: ".artifacts/openapi/zitadel/org/v2beta/org_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/org/v2beta/org_service.swagger.json", outputDir: "docs/apis/resources/org_service_v2beta", sidebarOptions: { groupPathsBy: "tag", @@ -351,7 +381,8 @@ module.exports = { }, }, project_v2beta: { - specPath: ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/project/v2beta/project_service.swagger.json", outputDir: "docs/apis/resources/project_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -359,7 +390,8 @@ module.exports = { }, }, instance_v2: { - specPath: ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", + specPath: + ".artifacts/openapi/zitadel/instance/v2beta/instance_service.swagger.json", outputDir: "docs/apis/resources/instance_service_v2", sidebarOptions: { groupPathsBy: "tag", @@ -382,13 +414,16 @@ module.exports = { }; }, ], - themes: [ "docusaurus-theme-github-codeblock", "docusaurus-theme-openapi-docs"], + themes: [ + "docusaurus-theme-github-codeblock", + "docusaurus-theme-openapi-docs", + ], future: { v4: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 experimental_faster: { swcJsLoader: false, // Disabled because of memory usage > 8GB which is a problem on vercel default runners swcJsMinimizer: true, - swcHtmlMinimizer : true, + swcHtmlMinimizer: true, lightningCssMinimizer: true, mdxCrossCompilerCache: true, ssgWorkerThreads: false, // Disabled because of some problems related to https://github.com/facebook/docusaurus/issues/11040 diff --git a/docs/package.json b/docs/package.json index 2c9eb8bb84..2e1214f378 100644 --- a/docs/package.json +++ b/docs/package.json @@ -29,6 +29,7 @@ "@docusaurus/theme-search-algolia": "^3.8.1", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", + "@inkeep/cxkit-docusaurus": "^0.5.89", "autoprefixer": "^10.4.13", "clsx": "^1.2.1", "docusaurus-plugin-image-zoom": "^3.0.1", diff --git a/docs/yarn.lock b/docs/yarn.lock index c933386f97..c48c5b8bd6 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -29,126 +29,126 @@ resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.9.tgz#5f38868f7cb1d54b014b17a10fc4f7e79d427fa8" integrity sha512-iDf05JDQ7I0b7JEA/9IektxN/80a2MZ1ToohfmNS3rfeuQnIKI3IJlIafD0xu4StbtQTghx9T3Maa97ytkXenQ== -"@algolia/client-abtesting@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.25.0.tgz#012204f1614e1a71366fb1e117c8f195186ff081" - integrity sha512-1pfQulNUYNf1Tk/svbfjfkLBS36zsuph6m+B6gDkPEivFmso/XnRgwDvjAx80WNtiHnmeNjIXdF7Gos8+OLHqQ== +"@algolia/client-abtesting@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.29.0.tgz#af9928f3b206cc5224e30256ea27d4e4d6023f22" + integrity sha512-AM/6LYMSTnZvAT5IarLEKjYWOdV+Fb+LVs8JRq88jn8HH6bpVUtjWdOZXqX1hJRXuCAY8SdQfb7F8uEiMNXdYQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-analytics@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.25.0.tgz#eba015bfafb3dbb82712c9160a00717a5974ff71" - integrity sha512-AFbG6VDJX/o2vDd9hqncj1B6B4Tulk61mY0pzTtzKClyTDlNP0xaUiEKhl6E7KO9I/x0FJF5tDCm0Hn6v5x18A== +"@algolia/client-analytics@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.29.0.tgz#d71b2f6e6c77c390343ee0ab73806378adb295eb" + integrity sha512-La34HJh90l0waw3wl5zETO8TuukeUyjcXhmjYZL3CAPLggmKv74mobiGRIb+mmBENybiFDXf/BeKFLhuDYWMMQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-common@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.25.0.tgz#2def8947efe849266057d92f67d1b8d83de0c005" - integrity sha512-il1zS/+Rc6la6RaCdSZ2YbJnkQC6W1wiBO8+SH+DE6CPMWBU6iDVzH0sCKSAtMWl9WBxoN6MhNjGBnCv9Yy2bA== +"@algolia/client-common@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.29.0.tgz#0908e90c5dc881be08eab4e595bf981e23525474" + integrity sha512-T0lzJH/JiCxQYtCcnWy7Jf1w/qjGDXTi2npyF9B9UsTvXB97GRC6icyfXxe21mhYvhQcaB1EQ/J2575FXxi2rA== -"@algolia/client-insights@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.25.0.tgz#b87df8614b96c4cc9c9aa7765cce07fa70864fa8" - integrity sha512-blbjrUH1siZNfyCGeq0iLQu00w3a4fBXm0WRIM0V8alcAPo7rWjLbMJMrfBtzL9X5ic6wgxVpDADXduGtdrnkw== +"@algolia/client-insights@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.29.0.tgz#80ca3c3d16ff2fa78b3a6a091a10ae508977dffa" + integrity sha512-A39F1zmHY9aev0z4Rt3fTLcGN5AG1VsVUkVWy6yQG5BRDScktH+U5m3zXwThwniBTDV1HrPgiGHZeWb67GkR2Q== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-personalization@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.25.0.tgz#74b041f0e7d91e1009c131c8d716c34e4d45c30f" - integrity sha512-aywoEuu1NxChBcHZ1pWaat0Plw7A8jDMwjgRJ00Mcl7wGlwuPt5dJ/LTNcg3McsEUbs2MBNmw0ignXBw9Tbgow== +"@algolia/client-personalization@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.29.0.tgz#1bc8882fe889ad25132794b7beecf1cfc0783acc" + integrity sha512-ibxmh2wKKrzu5du02gp8CLpRMeo+b/75e4ORct98CT7mIxuYFXowULwCd6cMMkz/R0LpKXIbTUl15UL5soaiUQ== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-query-suggestions@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.25.0.tgz#e92d935d9e2994f790d43c64d3518d81070a3888" - integrity sha512-a/W2z6XWKjKjIW1QQQV8PTTj1TXtaKx79uR3NGBdBdGvVdt24KzGAaN7sCr5oP8DW4D3cJt44wp2OY/fZcPAVA== +"@algolia/client-query-suggestions@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.29.0.tgz#784001417cee2ffde376f10074a477eef1eb095d" + integrity sha512-VZq4/AukOoJC2WSwF6J5sBtt+kImOoBwQc1nH3tgI+cxJBg7B77UsNC+jT6eP2dQCwGKBBRTmtPLUTDDnHpMgA== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/client-search@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.25.0.tgz#dc38ca1015f2f4c9f5053a4517f96fb28a2117f8" - integrity sha512-9rUYcMIBOrCtYiLX49djyzxqdK9Dya/6Z/8sebPn94BekT+KLOpaZCuc6s0Fpfq7nx5J6YY5LIVFQrtioK9u0g== +"@algolia/client-search@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.29.0.tgz#91c9a036b6677d954cd87d9262850f73f145bf81" + integrity sha512-cZ0Iq3OzFUPpgszzDr1G1aJV5UMIZ4VygJ2Az252q4Rdf5cQMhYEIKArWY/oUjMhQmosM8ygOovNq7gvA9CdCg== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" "@algolia/events@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@algolia/events/-/events-4.0.1.tgz#fd39e7477e7bc703d7f893b556f676c032af3950" integrity sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ== -"@algolia/ingestion@1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.25.0.tgz#4d13c56dda0a05c7bacb0e3ef5866292dfd86ed5" - integrity sha512-jJeH/Hk+k17Vkokf02lkfYE4A+EJX+UgnMhTLR/Mb+d1ya5WhE+po8p5a/Nxb6lo9OLCRl6w3Hmk1TX1e9gVbQ== +"@algolia/ingestion@1.29.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.29.0.tgz#9d7f30a7161b1cb612309f8240aa471faac8a21f" + integrity sha512-scBXn0wO5tZCxmO6evfa7A3bGryfyOI3aoXqSQBj5SRvNYXaUlFWQ/iKI70gRe/82ICwE0ICXbHT/wIvxOW7vw== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/monitoring@1.25.0": - version "1.25.0" - resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.25.0.tgz#d59360cfe556338519d05a9d8107147e9dbcb020" - integrity sha512-Ls3i1AehJ0C6xaHe7kK9vPmzImOn5zBg7Kzj8tRYIcmCWVyuuFwCIsbuIIz/qzUf1FPSWmw0TZrGeTumk2fqXg== +"@algolia/monitoring@1.29.0": + version "1.29.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.29.0.tgz#919f86b7c53f1ea7c78f4c0ed9bd7917c1ca3a67" + integrity sha512-FGWWG9jLFhsKB7YiDjM2dwQOYnWu//7Oxrb2vT96N7+s+hg1mdHHfHNRyEudWdxd4jkMhBjeqNA21VbTiOIPVg== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/recommend@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.25.0.tgz#b96f12c85aa74a0326982c7801fcd4a610b420f4" - integrity sha512-79sMdHpiRLXVxSjgw7Pt4R1aNUHxFLHiaTDnN2MQjHwJ1+o3wSseb55T9VXU4kqy3m7TUme3pyRhLk5ip/S4Mw== +"@algolia/recommend@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.29.0.tgz#8f2e5fe2e43e6d1dfa488b4c095404e46d0e1b0c" + integrity sha512-xte5+mpdfEARAu61KXa4ewpjchoZuJlAlvQb8ptK6hgHlBHDnYooy1bmOFpokaAICrq/H9HpoqNUX71n+3249A== dependencies: - "@algolia/client-common" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-common" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" -"@algolia/requester-browser-xhr@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.25.0.tgz#c194fa5f49206b9343e6646c41bfbca2a3f2ac54" - integrity sha512-JLaF23p1SOPBmfEqozUAgKHQrGl3z/Z5RHbggBu6s07QqXXcazEsub5VLonCxGVqTv6a61AAPr8J1G5HgGGjEw== +"@algolia/requester-browser-xhr@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.29.0.tgz#c3cec914716160d3d972ff09b3b35093916cb5bb" + integrity sha512-og+7Em75aPHhahEUScq2HQ3J7ULN63Levtd87BYMpn6Im5d5cNhaC4QAUsXu6LWqxRPgh4G+i+wIb6tVhDhg2A== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" -"@algolia/requester-fetch@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.25.0.tgz#231a2d0da2397d141f80b8f28e2cb6e3d219d38d" - integrity sha512-rtzXwqzFi1edkOF6sXxq+HhmRKDy7tz84u0o5t1fXwz0cwx+cjpmxu/6OQKTdOJFS92JUYHsG51Iunie7xbqfQ== +"@algolia/requester-fetch@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.29.0.tgz#3d885d73ab116c4c1ae88e7e6fb3b022cba45ce8" + integrity sha512-JCxapz7neAy8hT/nQpCvOrI5JO8VyQ1kPvBiaXWNC1prVq0UMYHEL52o1BsPvtXfdQ7BVq19OIq6TjOI06mV/w== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" -"@algolia/requester-node-http@5.25.0": - version "5.25.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.25.0.tgz#0ce13c550890de21c558b04381535d2d245a3725" - integrity sha512-ZO0UKvDyEFvyeJQX0gmZDQEvhLZ2X10K+ps6hViMo1HgE2V8em00SwNsQ+7E/52a+YiBkVWX61pJJJE44juDMQ== +"@algolia/requester-node-http@5.29.0": + version "5.29.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.29.0.tgz#9e8fb975c392ba1a99b8774856cfc892ed17819e" + integrity sha512-lVBD81RBW5VTdEYgnzCz7Pf9j2H44aymCP+/eHGJu4vhU+1O8aKf3TVBgbQr5UM6xoe8IkR/B112XY6YIG2vtg== dependencies: - "@algolia/client-common" "5.25.0" + "@algolia/client-common" "5.29.0" "@alloc/quick-lru@^5.2.0": version "5.2.0" @@ -217,9 +217,9 @@ integrity sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw== "@babel/compat-data@^7.27.2": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.3.tgz#cc49c2ac222d69b889bf34c795f537c0c6311111" - integrity sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82" + integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg== "@babel/core@^7.21.3": version "7.24.7" @@ -243,19 +243,19 @@ semver "^6.3.1" "@babel/core@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.3.tgz#d7d05502bccede3cab36373ed142e6a1df554c2f" - integrity sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA== + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce" + integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g== dependencies: "@ampproject/remapping" "^2.2.0" "@babel/code-frame" "^7.27.1" "@babel/generator" "^7.27.3" "@babel/helper-compilation-targets" "^7.27.2" "@babel/helper-module-transforms" "^7.27.3" - "@babel/helpers" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/helpers" "^7.27.4" + "@babel/parser" "^7.27.4" "@babel/template" "^7.27.2" - "@babel/traverse" "^7.27.3" + "@babel/traverse" "^7.27.4" "@babel/types" "^7.27.3" convert-source-map "^2.0.0" debug "^4.1.0" @@ -274,11 +274,11 @@ jsesc "^2.5.1" "@babel/generator@^7.25.9", "@babel/generator@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.3.tgz#ef1c0f7cfe3b5fc8cbb9f6cc69f93441a68edefc" - integrity sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.27.5.tgz#3eb01866b345ba261b04911020cbe22dd4be8c8c" + integrity sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw== dependencies: - "@babel/parser" "^7.27.3" + "@babel/parser" "^7.27.5" "@babel/types" "^7.27.3" "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.25" @@ -628,13 +628,13 @@ "@babel/template" "^7.27.0" "@babel/types" "^7.27.0" -"@babel/helpers@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.3.tgz#387d65d279290e22fe7a47a8ffcd2d0c0184edd0" - integrity sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg== +"@babel/helpers@^7.27.4": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.27.6.tgz#6456fed15b2cb669d2d1fabe84b66b34991d812c" + integrity sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug== dependencies: "@babel/template" "^7.27.2" - "@babel/types" "^7.27.3" + "@babel/types" "^7.27.6" "@babel/highlight@^7.24.7": version "7.24.7" @@ -658,10 +658,10 @@ dependencies: "@babel/types" "^7.27.0" -"@babel/parser@^7.27.2", "@babel/parser@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.3.tgz#1b7533f0d908ad2ac545c4d05cbe2fb6dc8cfaaf" - integrity sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw== +"@babel/parser@^7.27.2", "@babel/parser@^7.27.4", "@babel/parser@^7.27.5": + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.27.5.tgz#ed22f871f110aa285a6fd934a0efed621d118826" + integrity sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg== dependencies: "@babel/types" "^7.27.3" @@ -983,9 +983,9 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-transform-block-scoping@^7.27.1": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.3.tgz#a21f37e222dc0a7b91c3784fa3bd4edf8d7a6dc1" - integrity sha512-+F8CnfhuLhwUACIJMLWnjz6zvzYM2r0yeIHKlbgfw7ml8rOMJsXNXV/hyRcb3nb493gRs4WvYpQAndWj/qQmkQ== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.5.tgz#98c37485d815533623d992fd149af3e7b3140157" + integrity sha512-JF6uE2s67f0y2RZcm2kpAUEbD50vH62TyWVebxwHAlbSdM49VqPz8t4a1uIjp4NIOIZ4xzLfjY5emt/RCyC7TQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1595,9 +1595,9 @@ regenerator-transform "^0.15.2" "@babel/plugin-transform-regenerator@^7.27.1": - version "7.27.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz#0a471df9213416e44cd66bf67176b66f65768401" - integrity sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw== + version "7.27.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.5.tgz#0c01f4e0e4cced15f68ee14b9c76dac9813850c7" + integrity sha512-uhB8yHerfe3MWnuLAhEbeQ4afVoqv8BQsPqrTv7e/jZ9y00kJL6l9a/f4OWaKxotmjzewfEyXE1vgDJenkQ2/Q== dependencies: "@babel/helper-plugin-utils" "^7.27.1" @@ -1624,9 +1624,9 @@ "@babel/helper-plugin-utils" "^7.27.1" "@babel/plugin-transform-runtime@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.3.tgz#ad35f1eff5ba18a5e23f7270e939fb5a59d3ec0b" - integrity sha512-bA9ZL5PW90YwNgGfjg6U+7Qh/k3zCEQJ06BFgAGRp/yMjw9hP9UGbGPtx3KSOkHGljEPCCxaE+PH4fUR2h1sDw== + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.27.4.tgz#dee5c5db6543313d1ae1b4b1ec122ff1e77352b9" + integrity sha512-D68nR5zxU64EUzV8i7T3R5XP0Xhrou/amNnddsRQssx6GrTLdZl1rLxyjtVZBd+v/NVX4AbTPOB5aU8thAZV1A== dependencies: "@babel/helper-module-imports" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" @@ -2013,13 +2013,13 @@ integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== "@babel/runtime-corejs3@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.3.tgz#b971a4a0a376171e266629152e74ef50e9931f79" - integrity sha512-ZYcgrwb+dkWNcDlsTe4fH1CMdqMDSJ5lWFd1by8Si2pI54XcQjte/+ViIPqAk7EAWisaUxvQ89grv+bNX2x8zg== + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.27.6.tgz#97644153808a62898e7c05f3361501417db3c48b" + integrity sha512-vDVrlmRAY8z9Ul/HxT+8ceAru95LQgkSKiXkSYZvqtbkPSfhZJgpRp45Cldbh1GJ1kxzQkI70AqyrTI58KpaWQ== dependencies: core-js-pure "^3.30.2" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.20.13", "@babel/runtime@^7.23.2", "@babel/runtime@^7.26.0", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.27.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762" integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw== @@ -2027,9 +2027,9 @@ regenerator-runtime "^0.14.0" "@babel/runtime@^7.25.9": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.3.tgz#10491113799fb8d77e1d9273384d5d68deeea8f6" - integrity sha512-7EYtGezsdiDMyY80+65EzwiGmcJqpmcZCojSXaRgdrBaGtWTgDZKq69cPIVped6MkIM78cTQ2GOiEYjwOlG4xw== + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6" + integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== "@babel/template@^7.24.7": version "7.24.7" @@ -2074,14 +2074,14 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.3.tgz#8b62a6c2d10f9d921ba7339c90074708509cffae" - integrity sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ== +"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3", "@babel/traverse@^7.27.4": + version "7.27.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.27.4.tgz#b0045ac7023c8472c3d35effd7cc9ebd638da6ea" + integrity sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA== dependencies: "@babel/code-frame" "^7.27.1" "@babel/generator" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/parser" "^7.27.4" "@babel/template" "^7.27.2" "@babel/types" "^7.27.3" debug "^4.3.1" @@ -2104,10 +2104,10 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@babel/types@^7.27.1", "@babel/types@^7.27.3": - version "7.27.3" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.3.tgz#c0257bedf33aad6aad1f406d35c44758321eb3ec" - integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== +"@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.27.6": + version "7.27.6" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.27.6.tgz#a434ca7add514d4e646c80f7375c0aa2befc5535" + integrity sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q== dependencies: "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" @@ -3072,6 +3072,33 @@ resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-5.5.3.tgz#18e3af6b8eae7984072bbeb0c0858474d7c4cefe" integrity sha512-R11tGE6yIFwqpaIqcfkcg7AICXzFg14+5h5v0TfF/9+RMDL6jhzCy/pxHVOfbALGdtVYdt6JdR21tuxEgl34dw== +"@floating-ui/core@^1.6.0": + version "1.6.9" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.9.tgz#64d1da251433019dafa091de9b2886ff35ec14e6" + integrity sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw== + dependencies: + "@floating-ui/utils" "^0.2.9" + +"@floating-ui/dom@^1.0.0": + version "1.6.13" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.13.tgz#a8a938532aea27a95121ec16e667a7cbe8c59e34" + integrity sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w== + dependencies: + "@floating-ui/core" "^1.6.0" + "@floating-ui/utils" "^0.2.9" + +"@floating-ui/react-dom@^2.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.2.tgz#a1349bbf6a0e5cb5ded55d023766f20a4d439a31" + integrity sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@floating-ui/utils@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.9.tgz#50dea3616bc8191fb8e112283b49eaff03e78429" + integrity sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg== + "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -3121,6 +3148,104 @@ local-pkg "^1.0.0" mlly "^1.7.4" +"@inkeep/cxkit-color-mode@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-color-mode/-/cxkit-color-mode-0.5.89.tgz#4a5471b3dc453262ef0277908c30e108b1095331" + integrity sha512-h89/i67uEiJh0Bqf/dt9nJWv3IjCnmu96nkomocZ3evPKrLeBq13IygFIxtvmvXfx1QBBdmAD+x933rc5RcgFA== + +"@inkeep/cxkit-docusaurus@^0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-docusaurus/-/cxkit-docusaurus-0.5.89.tgz#aff4035fe2bb69401d1677393dcd2943bf3e0d1f" + integrity sha512-z4OxGLoPVbk6ZcKW5i/MrGJUx6Wyc5zh2mxOt2IHTQgFL3XiArCKds0R8jSxSi8SB/a9j5Wm/X0FinT+or9NKA== + dependencies: + "@inkeep/cxkit-react" "0.5.89" + merge-anything "5.1.7" + path "^0.12.7" + +"@inkeep/cxkit-primitives@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-primitives/-/cxkit-primitives-0.5.89.tgz#1f8252f18754aab2c28dd0e1436381b4323d2c0b" + integrity sha512-ugC80ivuimKmzcm8RktAL9C7YHss7CneerbHnNy+uipCx9ZcIY5dRfgMqIdoLG6fL7fhSm6E0QElGL8YjjfN6g== + dependencies: + "@inkeep/cxkit-color-mode" "0.5.89" + "@inkeep/cxkit-theme" "0.5.89" + "@inkeep/cxkit-types" "0.5.89" + "@radix-ui/primitive" "^1.1.1" + "@radix-ui/react-avatar" "1.1.2" + "@radix-ui/react-checkbox" "1.1.3" + "@radix-ui/react-compose-refs" "^1.1.1" + "@radix-ui/react-context" "^1.1.1" + "@radix-ui/react-dismissable-layer" "^1.1.5" + "@radix-ui/react-focus-guards" "^1.1.1" + "@radix-ui/react-focus-scope" "^1.1.2" + "@radix-ui/react-hover-card" "^1.1.6" + "@radix-ui/react-id" "^1.1.0" + "@radix-ui/react-popover" "1.1.6" + "@radix-ui/react-portal" "^1.1.4" + "@radix-ui/react-presence" "^1.1.2" + "@radix-ui/react-primitive" "^2.0.2" + "@radix-ui/react-scroll-area" "1.2.2" + "@radix-ui/react-select" "^2.1.7" + "@radix-ui/react-slot" "^1.2.0" + "@radix-ui/react-tabs" "^1.1.4" + "@radix-ui/react-tooltip" "1.1.6" + "@radix-ui/react-use-callback-ref" "^1.1.0" + "@radix-ui/react-use-controllable-state" "^1.1.0" + "@zag-js/focus-trap" "^1.7.0" + "@zag-js/presence" "^1.13.1" + "@zag-js/react" "^1.13.1" + altcha-lib "^1.2.0" + aria-hidden "^1.2.4" + dequal "^2.0.3" + humps "2.0.1" + lucide-react "^0.503.0" + marked "^15.0.9" + merge-anything "5.1.7" + openai "4.78.1" + prism-react-renderer "2.4.1" + react-error-boundary "^6.0.0" + react-hook-form "7.54.2" + react-markdown "9.0.3" + react-remove-scroll "^2.7.1" + react-svg "16.3.0" + react-textarea-autosize "8.5.7" + rehype-raw "7.0.0" + remark-gfm "^4.0.1" + unist-util-visit "^5.0.0" + use-sync-external-store "^1.4.0" + +"@inkeep/cxkit-react@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-react/-/cxkit-react-0.5.89.tgz#4bc37852bc6161ed4dc5b44b3ceb8beddf49f6f8" + integrity sha512-v86J6xe86kgKfDzlNGZSGHQ3PB8KJ46ra8xnVV4RrRjp7kPGiR7MvGfalFcSiaSEQzWiAcKAl4gya/AF/J/OZw== + dependencies: + "@inkeep/cxkit-styled" "0.5.89" + "@radix-ui/react-use-controllable-state" "^1.1.0" + lucide-react "^0.503.0" + +"@inkeep/cxkit-styled@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-styled/-/cxkit-styled-0.5.89.tgz#e113ee393f5055457281a52cfb8221dd14f70661" + integrity sha512-w9V3vYuq4ytluow16RO/0/V1s9PSBqOjZdvATdn+jy06gUn5ClNAiJ79m34fB3Ep0Y6o2m+obujX4njw4A+LPw== + dependencies: + "@inkeep/cxkit-primitives" "0.5.89" + class-variance-authority "0.7.1" + clsx "2.1.1" + merge-anything "5.1.7" + tailwind-merge "2.6.0" + +"@inkeep/cxkit-theme@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-theme/-/cxkit-theme-0.5.89.tgz#b1f9f7be2a87f25b8c6b2c4eb654b998b6bd7cc8" + integrity sha512-Yji2OCDi2buYZQXY4tw93U6W3ZFDaNw7wgGLP+vTyRZaFBMGwW52zNOjewz2UL1jNGl6ublHXmWM+kjIC4b5SQ== + dependencies: + colorjs.io "0.5.2" + +"@inkeep/cxkit-types@0.5.89": + version "0.5.89" + resolved "https://registry.yarnpkg.com/@inkeep/cxkit-types/-/cxkit-types-0.5.89.tgz#f8db85cca7c8dbb72c6a035882435b5e0e86ca76" + integrity sha512-zz6945Ex9kSpIUeZaVAX4h6HeCaOt28BzZyuprQWXIpzvAlFKuKDNV1Zm5umEglaiGSx+T9J6WViy3PoRXwTtA== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -3238,10 +3363,10 @@ dependencies: "@types/mdx" "^2.0.0" -"@mermaid-js/parser@^0.4.0": - version "0.4.0" - resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.4.0.tgz#c1de1f5669f8fcbd0d0c9d124927d36ddc00d8a6" - integrity sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA== +"@mermaid-js/parser@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.5.0.tgz#63d676e930b0cfd6abfeadee46fb228761438ce6" + integrity sha512-AiaN7+VjXC+3BYE+GwNezkpjIcCI2qIMB/K4S2/vMWe0q/XJCBbx5+K7iteuz7VyltX9iAK4FmVTvGc9kjOV4w== dependencies: langium "3.3.1" @@ -3429,6 +3554,551 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.25.tgz#f077fdc0b5d0078d30893396ff4827a13f99e817" integrity sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ== +"@radix-ui/number@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.0.tgz#1e95610461a09cdf8bb05c152e76ca1278d5da46" + integrity sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ== + +"@radix-ui/number@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090" + integrity sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g== + +"@radix-ui/primitive@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.1.tgz#fc169732d755c7fbad33ba8d0cd7fd10c90dc8e3" + integrity sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA== + +"@radix-ui/primitive@1.1.2", "@radix-ui/primitive@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.2.tgz#83f415c4425f21e3d27914c12b3272a32e3dae65" + integrity sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA== + +"@radix-ui/react-arrow@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz#2103721933a8bfc6e53bbfbdc1aaad5fc8ba0dd7" + integrity sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + +"@radix-ui/react-arrow@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.2.tgz#30c0d574d7bb10eed55cd7007b92d38b03c6b2ab" + integrity sha512-G+KcpzXHq24iH0uGG/pF8LyzpFJYGD4RfLjCIBfGdSLXvjLHST31RUiRVrupIBMvIppMgSzQ6l66iAxl03tdlg== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + +"@radix-ui/react-arrow@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.1.3.tgz#8926eb1d87f73c2e047eac96703949f168c85861" + integrity sha512-2dvVU4jva0qkNZH6HHWuSz5FN5GeU5tymvCgutF8WaXz9WnD1NgUhy73cqzkjkN4Zkn8lfTPv5JIfrC221W+Nw== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + +"@radix-ui/react-avatar@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz#24af4c66bb5271460a4a6b74c4f4f9d4789d3d90" + integrity sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig== + dependencies: + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-checkbox@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.1.3.tgz#0e2ab913fddf3c88603625f7a9457d73882c8a32" + integrity sha512-HD7/ocp8f1B3e6OHygH0n7ZKjONkhciy1Nh0yuBgObqThc3oyx+vuMfFHKAknXRHHWVE9XvXStxJFyjUmB8PIw== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + +"@radix-ui/react-collection@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.1.3.tgz#cfd46dcea5a8ab064d91798feeb46faba4032930" + integrity sha512-mM2pxoQw5HJ49rkzwOs7Y6J4oYH22wS8BfK2/bBxROlI4xuR0c4jEenQP63LlTlDkO6Buj2Vt+QYAYcOgqtrXA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-slot" "1.2.0" + +"@radix-ui/react-compose-refs@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz#6f766faa975f8738269ebb8a23bad4f5a8d2faec" + integrity sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw== + +"@radix-ui/react-compose-refs@1.1.2", "@radix-ui/react-compose-refs@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz#a2c4c47af6337048ee78ff6dc0d090b390d2bb30" + integrity sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg== + +"@radix-ui/react-context@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.1.tgz#82074aa83a472353bb22e86f11bcbd1c61c4c71a" + integrity sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q== + +"@radix-ui/react-context@1.1.2", "@radix-ui/react-context@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-context/-/react-context-1.1.2.tgz#61628ef269a433382c364f6f1e3788a6dc213a36" + integrity sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA== + +"@radix-ui/react-direction@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.0.tgz#a7d39855f4d077adc2a1922f9c353c5977a09cdc" + integrity sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg== + +"@radix-ui/react-direction@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-direction/-/react-direction-1.1.1.tgz#39e5a5769e676c753204b792fbe6cf508e550a14" + integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== + +"@radix-ui/react-dismissable-layer@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.3.tgz#4ee0f0f82d53bf5bd9db21665799bb0d1bad5ed8" + integrity sha512-onrWn/72lQoEucDmJnr8uczSNTujT0vJnA/X5+3AkChVPowr8n1yvIKIabhWyMQeMvvmdpsvcyDqx3X1LEXCPg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + +"@radix-ui/react-dismissable-layer@1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz#96dde2be078c694a621e55e047406c58cd5fe774" + integrity sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-escape-keydown" "1.1.0" + +"@radix-ui/react-dismissable-layer@1.1.6", "@radix-ui/react-dismissable-layer@^1.1.5": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.6.tgz#e72c156cac7b07614fe8e3a039ab7081ce413686" + integrity sha512-7gpgMT2gyKym9Jz2ZhlRXSg2y6cNQIK8d/cqBZ0RBCaps8pFryCWXiUKI+uHGFrhMrbGUP7U6PWgiXzIxoyF3Q== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-escape-keydown" "1.1.1" + +"@radix-ui/react-focus-guards@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz#8635edd346304f8b42cae86b05912b61aef27afe" + integrity sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg== + +"@radix-ui/react-focus-guards@1.1.2", "@radix-ui/react-focus-guards@^1.1.1": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz#4ec9a7e50925f7fb661394460045b46212a33bed" + integrity sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA== + +"@radix-ui/react-focus-scope@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz#c0a4519cd95c772606a82fc5b96226cd7fdd2602" + integrity sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-focus-scope@1.1.3", "@radix-ui/react-focus-scope@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.3.tgz#eac83a3aac700db17650b41b30724deffac5b28a" + integrity sha512-4XaDlq0bPt7oJwR+0k0clCiCO/7lO7NKZTAaJBYxDNQT/vj4ig0/UvctrRscZaFREpRvUTkpKR96ov1e6jptQg== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-hover-card@^1.1.6": + version "1.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.7.tgz#01b2f956daeb8a1193ccdb36c9c00943120bf2d4" + integrity sha512-HwM03kP8psrv21J1+9T/hhxi0f5rARVbqIZl9+IAq13l4j4fX+oGIuxisukZZmebO7J35w9gpoILvtG8bbph0w== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-dismissable-layer" "1.1.6" + "@radix-ui/react-popper" "1.2.3" + "@radix-ui/react-portal" "1.1.5" + "@radix-ui/react-presence" "1.1.3" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-id@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.0.tgz#de47339656594ad722eb87f94a6b25f9cffae0ed" + integrity sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-id@1.1.1", "@radix-ui/react-id@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-id/-/react-id-1.1.1.tgz#1404002e79a03fe062b7e3864aa01e24bd1471f7" + integrity sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-popover@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.1.6.tgz#699634dbc7899429f657bb590d71fb3ca0904087" + integrity sha512-NQouW0x4/GnkFJ/pRqsIS3rM/k97VzKnVb2jB7Gq7VEGPy5g7uNV1ykySFt7eWSp3i2uSGFwaJcvIRJBAHmmFg== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.5" + "@radix-ui/react-focus-guards" "1.1.1" + "@radix-ui/react-focus-scope" "1.1.2" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.2" + "@radix-ui/react-portal" "1.1.4" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-slot" "1.1.2" + "@radix-ui/react-use-controllable-state" "1.1.0" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + +"@radix-ui/react-popper@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.1.tgz#2fc66cfc34f95f00d858924e3bee54beae2dff0a" + integrity sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-popper@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.2.tgz#d2e1ee5a9b24419c5936a1b7f6f472b7b412b029" + integrity sha512-Rvqc3nOpwseCyj/rgjlJDYAgyfw7OC1tTkKn2ivhaMGcYt8FSBlahHOZak2i3QwkRXUXgGgzeEe2RuqeEHuHgA== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.2" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + "@radix-ui/react-use-rect" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-popper@1.2.3": + version "1.2.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.2.3.tgz#3b6ef3388fd209bb46341e1e40125b75f07f1304" + integrity sha512-iNb9LYUMkne9zIahukgQmHlSBp9XWGeQQ7FvUGNk45ywzOb6kQa+Ca38OphXlWDiKvyneo9S+KSJsLfLt8812A== + dependencies: + "@floating-ui/react-dom" "^2.0.0" + "@radix-ui/react-arrow" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-rect" "1.1.1" + "@radix-ui/react-use-size" "1.1.1" + "@radix-ui/rect" "1.1.1" + +"@radix-ui/react-portal@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.3.tgz#b0ea5141103a1671b715481b13440763d2ac4440" + integrity sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-portal@1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.4.tgz#ff5401ff63c8a825c46eea96d3aef66074b8c0c8" + integrity sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA== + dependencies: + "@radix-ui/react-primitive" "2.0.2" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-portal@1.1.5", "@radix-ui/react-portal@^1.1.4": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@radix-ui/react-portal/-/react-portal-1.1.5.tgz#50ed6bee2d895c9a9dfc28625f24b8483b74d431" + integrity sha512-ps/67ZqsFm+Mb6lSPJpfhRLrVL2i2fntgCmGMqqth4eaGUf+knAuuRtWVJrNjUhExgmdRqftSgzpf0DF0n6yXA== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-presence@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.2.tgz#bb764ed8a9118b7ec4512da5ece306ded8703cdc" + integrity sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-presence@1.1.3", "@radix-ui/react-presence@^1.1.2": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-presence/-/react-presence-1.1.3.tgz#ce3400caec9892ceb862f96ddaa2add080c09b90" + integrity sha512-IrVLIhskYhH3nLvtcBLQFZr61tBG7wx7O3kEmdzcYwRGAEBmBicGGL7ATzNgruYJ3xBTbuzEEq9OXJM3PAX3tA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-primitive@2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz#6d9efc550f7520135366f333d1e820cf225fad9e" + integrity sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg== + dependencies: + "@radix-ui/react-slot" "1.1.1" + +"@radix-ui/react-primitive@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz#ac8b7854d87b0d7af388d058268d9a7eb64ca8ef" + integrity sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w== + dependencies: + "@radix-ui/react-slot" "1.1.2" + +"@radix-ui/react-primitive@2.0.3", "@radix-ui/react-primitive@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-primitive/-/react-primitive-2.0.3.tgz#13c654dc4754558870a9c769f6febe5980a1bad8" + integrity sha512-Pf/t/GkndH7CQ8wE2hbkXA+WyZ83fhQQn5DDmwDiDo6AwN/fhaH8oqZ0jRjMrO2iaMhDi6P1HRx6AZwyMinY1g== + dependencies: + "@radix-ui/react-slot" "1.2.0" + +"@radix-ui/react-roving-focus@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.3.tgz#c992b9d30c795f5f5a668853db8f4a6e07b7284d" + integrity sha512-ufbpLUjZiOg4iYgb2hQrWXEPYX6jOLBbR27bDyAff5GYMRrCzcze8lukjuXVUQvJ6HZe8+oL+hhswDcjmcgVyg== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-collection" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-scroll-area@1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz#28e34fd4d83e9de5d987c5e8914a7bd8be9546a5" + integrity sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g== + dependencies: + "@radix-ui/number" "1.1.0" + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-direction" "1.1.0" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-callback-ref" "1.1.0" + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-select@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-select/-/react-select-2.1.7.tgz#68561488ca54cad07352b3f2c2d29e0da28bbaa0" + integrity sha512-exzGIRtc7S8EIM2KjFg+7lJZsH7O7tpaBaJbBNVDnOZNhtoQ2iV+iSNfi2Wth0m6h3trJkMVvzAehB3c6xj/3Q== + dependencies: + "@radix-ui/number" "1.1.1" + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-collection" "1.1.3" + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.6" + "@radix-ui/react-focus-guards" "1.1.2" + "@radix-ui/react-focus-scope" "1.1.3" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-popper" "1.2.3" + "@radix-ui/react-portal" "1.1.5" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-slot" "1.2.0" + "@radix-ui/react-use-callback-ref" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.1" + "@radix-ui/react-use-layout-effect" "1.1.1" + "@radix-ui/react-use-previous" "1.1.1" + "@radix-ui/react-visually-hidden" "1.1.3" + aria-hidden "^1.2.4" + react-remove-scroll "^2.6.3" + +"@radix-ui/react-slot@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.1.tgz#ab9a0ffae4027db7dc2af503c223c978706affc3" + integrity sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + +"@radix-ui/react-slot@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.1.2.tgz#daffff7b2bfe99ade63b5168407680b93c00e1c6" + integrity sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ== + dependencies: + "@radix-ui/react-compose-refs" "1.1.1" + +"@radix-ui/react-slot@1.2.0", "@radix-ui/react-slot@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.2.0.tgz#57727fc186ddb40724ccfbe294e1a351d92462ba" + integrity sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + +"@radix-ui/react-tabs@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tabs/-/react-tabs-1.1.4.tgz#2e43f3ef3450143281e7c1491da1e5d7941b9826" + integrity sha512-fuHMHWSf5SRhXke+DbHXj2wVMo+ghVH30vhX3XVacdXqDl+J4XWafMIGOOER861QpBx1jxgwKXL2dQnfrsd8MQ== + dependencies: + "@radix-ui/primitive" "1.1.2" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-presence" "1.1.3" + "@radix-ui/react-primitive" "2.0.3" + "@radix-ui/react-roving-focus" "1.1.3" + "@radix-ui/react-use-controllable-state" "1.1.1" + +"@radix-ui/react-tooltip@1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@radix-ui/react-tooltip/-/react-tooltip-1.1.6.tgz#eab98e9a5c876ef0abfae3cfeee229870528ed06" + integrity sha512-TLB5D8QLExS1uDn7+wH/bjEmRurNMTzNrtq7IjaS4kjion9NtzsTGkvR5+i7yc9q01Pi2KMM2cN3f8UG4IvvXA== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-dismissable-layer" "1.1.3" + "@radix-ui/react-id" "1.1.0" + "@radix-ui/react-popper" "1.2.1" + "@radix-ui/react-portal" "1.1.3" + "@radix-ui/react-presence" "1.1.2" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-slot" "1.1.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-visually-hidden" "1.1.1" + +"@radix-ui/react-use-callback-ref@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1" + integrity sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw== + +"@radix-ui/react-use-callback-ref@1.1.1", "@radix-ui/react-use-callback-ref@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz#62a4dba8b3255fdc5cc7787faeac1c6e4cc58d40" + integrity sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg== + +"@radix-ui/react-use-controllable-state@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz#1321446857bb786917df54c0d4d084877aab04b0" + integrity sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-use-controllable-state@1.1.1", "@radix-ui/react-use-controllable-state@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.1.tgz#ec9c572072a6f269df7435c1652fbeebabe0f0c1" + integrity sha512-YnEXIy8/ga01Y1PN0VfaNH//MhA91JlEGVBDxDzROqwrAtG5Yr2QGEPz8A/rJA3C7ZAHryOYGaUv8fLSW2H/mg== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-use-escape-keydown@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz#31a5b87c3b726504b74e05dac1edce7437b98754" + integrity sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.0" + +"@radix-ui/react-use-escape-keydown@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz#b3fed9bbea366a118f40427ac40500aa1423cc29" + integrity sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g== + dependencies: + "@radix-ui/react-use-callback-ref" "1.1.1" + +"@radix-ui/react-use-layout-effect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz#3c2c8ce04827b26a39e442ff4888d9212268bd27" + integrity sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w== + +"@radix-ui/react-use-layout-effect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz#0c4230a9eed49d4589c967e2d9c0d9d60a23971e" + integrity sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ== + +"@radix-ui/react-use-previous@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.0.tgz#d4dd37b05520f1d996a384eb469320c2ada8377c" + integrity sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og== + +"@radix-ui/react-use-previous@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz#1a1ad5568973d24051ed0af687766f6c7cb9b5b5" + integrity sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ== + +"@radix-ui/react-use-rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz#13b25b913bd3e3987cc9b073a1a164bb1cf47b88" + integrity sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ== + dependencies: + "@radix-ui/rect" "1.1.0" + +"@radix-ui/react-use-rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz#01443ca8ed071d33023c1113e5173b5ed8769152" + integrity sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w== + dependencies: + "@radix-ui/rect" "1.1.1" + +"@radix-ui/react-use-size@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz#b4dba7fbd3882ee09e8d2a44a3eed3a7e555246b" + integrity sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.0" + +"@radix-ui/react-use-size@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz#6de276ffbc389a537ffe4316f5b0f24129405b37" + integrity sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ== + dependencies: + "@radix-ui/react-use-layout-effect" "1.1.1" + +"@radix-ui/react-visually-hidden@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.1.tgz#f7b48c1af50dfdc366e92726aee6d591996c5752" + integrity sha512-vVfA2IZ9q/J+gEamvj761Oq1FpWgCDaNOOIfbPVp2MVPLEomUr5+Vf7kJGwQ24YxZSlQVar7Bes8kyTo5Dshpg== + dependencies: + "@radix-ui/react-primitive" "2.0.1" + +"@radix-ui/react-visually-hidden@1.1.3": + version "1.1.3" + resolved "https://registry.yarnpkg.com/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.3.tgz#f704c49121859941a8bb50ff1e4f156058cacd0b" + integrity sha512-oXSF3ZQRd5fvomd9hmUCb2EHSZbPp3ZSHAHJJU/DlF9XoFkJBBW8RHU/E8WEH+RbSfJd/QFA0sl8ClJXknBwHQ== + dependencies: + "@radix-ui/react-primitive" "2.0.3" + +"@radix-ui/rect@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.0.tgz#f817d1d3265ac5415dadc67edab30ae196696438" + integrity sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg== + +"@radix-ui/rect@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@radix-ui/rect/-/rect-1.1.1.tgz#78244efe12930c56fd255d7923865857c41ac8cb" + integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== + "@redocly/ajv@^8.11.0": version "8.11.0" resolved "https://registry.yarnpkg.com/@redocly/ajv/-/ajv-8.11.0.tgz#2fad322888dc0113af026e08fceb3e71aae495ae" @@ -3691,152 +4361,152 @@ "@svgr/plugin-jsx" "8.1.0" "@svgr/plugin-svgo" "8.1.0" -"@swc/core-darwin-arm64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.29.tgz#bf66e3f4f00e6fe9d95e8a33f780e6c40fca946d" - integrity sha512-whsCX7URzbuS5aET58c75Dloby3Gtj/ITk2vc4WW6pSDQKSPDuONsIcZ7B2ng8oz0K6ttbi4p3H/PNPQLJ4maQ== +"@swc/core-darwin-arm64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.12.6.tgz#3d9166720df2dc00fa3b6cf90fce3e77a442a43e" + integrity sha512-yLiw+XzG+MilfFh0ON7qt67bfIr7UxB9JprhYReVOmLTBDmDVQSC3T4/vIuc+GwlX08ydnHy0ud4lIjTNW4uWg== -"@swc/core-darwin-x64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.11.29.tgz#0a77d2d79ef2c789f9d40a86784bbf52c5f9877f" - integrity sha512-S3eTo/KYFk+76cWJRgX30hylN5XkSmjYtCBnM4jPLYn7L6zWYEPajsFLmruQEiTEDUg0gBEWLMNyUeghtswouw== +"@swc/core-darwin-x64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.12.6.tgz#2bbc32c56f8bccf6958b73d46bdd5670aa31f4d9" + integrity sha512-qwg8ux5x5Gd1LmSUtL4s9mbyfzAjr5M6OtjO281dKHwc/GYiSc4j1urb2jNSo9FcMkfT78oAOW2L6HQiWv+j1A== -"@swc/core-linux-arm-gnueabihf@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.29.tgz#80fa3a6a36034ffdbbba73e26c8f27cb13111a33" - integrity sha512-o9gdshbzkUMG6azldHdmKklcfrcMx+a23d/2qHQHPDLUPAN+Trd+sDQUYArK5Fcm7TlpG4sczz95ghN0DMkM7g== +"@swc/core-linux-arm-gnueabihf@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.12.6.tgz#45d8bdef987c4f7fbc5b14640374f0e77904f304" + integrity sha512-pnkqH59JXBZu+MedaykMAC2or7tlUKeya7GKjzub+hkwxBP0ywWoFd+QYEdzp7QSziOt1VIHc4Wb9iZ2EfnzmA== -"@swc/core-linux-arm64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.29.tgz#42da87f445bc3e26da01d494246884006d9b9a1a" - integrity sha512-sLoaciOgUKQF1KX9T6hPGzvhOQaJn+3DHy4LOHeXhQqvBgr+7QcZ+hl4uixPKTzxk6hy6Hb0QOvQEdBAAR1gXw== +"@swc/core-linux-arm64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.12.6.tgz#f69616d4269e11cb93348437687e08f49f58cc36" + integrity sha512-h8+Ltx0NSEzIFHetkOYoQ+UQ59unYLuJ4wF6kCpxzS4HskRLjcngr1HgN0F/PRpptnrmJUPVQmfms/vjN8ndAQ== -"@swc/core-linux-arm64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.29.tgz#c9cec610525dc9e9b11ef26319db3780812dfa54" - integrity sha512-PwjB10BC0N+Ce7RU/L23eYch6lXFHz7r3NFavIcwDNa/AAqywfxyxh13OeRy+P0cg7NDpWEETWspXeI4Ek8otw== +"@swc/core-linux-arm64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.12.6.tgz#3277154e7b60213c0fa11e415c8a90b41563d76e" + integrity sha512-GZu3MnB/5qtBxKEH46hgVDaplEe4mp3ZmQ1O2UpFCv/u/Ji3Gar5w5g2wHCZoT5AOouAhP1bh7IAEqjG/fbVfg== -"@swc/core-linux-x64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.29.tgz#1cda2df38a4ab8905ba6ac3aa16e4ad710b6f2de" - integrity sha512-i62vBVoPaVe9A3mc6gJG07n0/e7FVeAvdD9uzZTtGLiuIfVfIBta8EMquzvf+POLycSk79Z6lRhGPZPJPYiQaA== +"@swc/core-linux-x64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.12.6.tgz#7d04d9437011396bf54c4ab0686c4db65a1283f8" + integrity sha512-WwJLQFzMW9ufVjM6k3le4HUgBFNunyt2oghjcgn2YjnKj0Ka2LrrBHCxfS7lgFSCQh/shib2wIlKXUnlTEWQJw== -"@swc/core-linux-x64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.29.tgz#5d634efff33f47c8d6addd84291ab606903d1cfd" - integrity sha512-YER0XU1xqFdK0hKkfSVX1YIyCvMDI7K07GIpefPvcfyNGs38AXKhb2byySDjbVxkdl4dycaxxhRyhQ2gKSlsFQ== +"@swc/core-linux-x64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.12.6.tgz#2f3e586eda3ee5d08b6b8de7714623d5a44ceb3c" + integrity sha512-rVGPNpI/sm8VVAhnB09Z/23OJP3ymouv6F4z4aYDbq/2JIwxqgpnl8gtMYP+Jw3XqabaFNjQmPiL15TvKCQaxQ== -"@swc/core-win32-arm64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.29.tgz#bc54f2e3f8f180113b7a092b1ee1eaaab24df62b" - integrity sha512-po+WHw+k9g6FAg5IJ+sMwtA/fIUL3zPQ4m/uJgONBATCVnDDkyW6dBA49uHNVtSEvjvhuD8DVWdFP847YTcITw== +"@swc/core-win32-arm64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.12.6.tgz#70211534238ed941efaf0a1b34310e937bd4afa7" + integrity sha512-EKDJ1+8vaIlJGMl2yvd2HklV4GNbpKKwNQcUQid6j91tFYz4/aByw+9vh/sDVG7ZNqdmdywSnLRo317UTt0zFg== -"@swc/core-win32-ia32-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.29.tgz#f1df344c06283643d1fe66c6931b350347b73722" - integrity sha512-h+NjOrbqdRBYr5ItmStmQt6x3tnhqgwbj9YxdGPepbTDamFv7vFnhZR0YfB3jz3UKJ8H3uGJ65Zw1VsC+xpFkg== +"@swc/core-win32-ia32-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.12.6.tgz#d3584da07b47904b547baced8b00c9e4d32110e7" + integrity sha512-jnULikZkR2fpZgFUQs7NsNIztavM1JdX+8Y+8FsfChBvMvziKxXtvUPGjeVJ8nzU1wgMnaeilJX9vrwuDGkA0Q== -"@swc/core-win32-x64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.29.tgz#a6f9dc1df66c8db96d70091abedd78cc52544724" - integrity sha512-Q8cs2BDV9wqDvqobkXOYdC+pLUSEpX/KvI0Dgfun1F+LzuLotRFuDhrvkU9ETJA6OnD2+Fn/ieHgloiKA/Mn/g== +"@swc/core-win32-x64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.12.6.tgz#8eff8c0d9e7c3b91bd9427e67483be90070b0f7d" + integrity sha512-jL2Dcdcc/QZiQnwByP1uIE4k/mTlapzUng7owtLD2tSBBi1d+jPIdXIefdv+nccYJKRA+lKG3rRB6Tk9GrC7Kg== "@swc/core@^1.7.39": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.11.29.tgz#bce20113c47fcd6251d06262b8b8c063f8e86a20" - integrity sha512-g4mThMIpWbNhV8G2rWp5a5/Igv8/2UFRJx2yImrLGMgrDDYZIopqZ/z0jZxDgqNA1QDx93rpwNF7jGsxVWcMlA== + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.12.6.tgz#1bf204f7afc59fde6cb2cef067de23af232d6ff6" + integrity sha512-TEpta6Gi02X1b2yDIzBOIr7dFprvq9jD8RbEVI2OcMrwklbCUx0Dz9TrAnklSOwRvYvH5JjCx8ht9E94oWiG7A== dependencies: "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.21" + "@swc/types" "^0.1.23" optionalDependencies: - "@swc/core-darwin-arm64" "1.11.29" - "@swc/core-darwin-x64" "1.11.29" - "@swc/core-linux-arm-gnueabihf" "1.11.29" - "@swc/core-linux-arm64-gnu" "1.11.29" - "@swc/core-linux-arm64-musl" "1.11.29" - "@swc/core-linux-x64-gnu" "1.11.29" - "@swc/core-linux-x64-musl" "1.11.29" - "@swc/core-win32-arm64-msvc" "1.11.29" - "@swc/core-win32-ia32-msvc" "1.11.29" - "@swc/core-win32-x64-msvc" "1.11.29" + "@swc/core-darwin-arm64" "1.12.6" + "@swc/core-darwin-x64" "1.12.6" + "@swc/core-linux-arm-gnueabihf" "1.12.6" + "@swc/core-linux-arm64-gnu" "1.12.6" + "@swc/core-linux-arm64-musl" "1.12.6" + "@swc/core-linux-x64-gnu" "1.12.6" + "@swc/core-linux-x64-musl" "1.12.6" + "@swc/core-win32-arm64-msvc" "1.12.6" + "@swc/core-win32-ia32-msvc" "1.12.6" + "@swc/core-win32-x64-msvc" "1.12.6" "@swc/counter@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/html-darwin-arm64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.11.29.tgz#7bd6d10115ffe155ecd757387b5aff318b02b5a0" - integrity sha512-q53kn/HI0n/+pecsOB2gxqITbRAhtBG7VI520SIWuCGXHPsTQ/1VOrhLMNvyfw1xVhRyFal7BpAvfGUORCl0sw== +"@swc/html-darwin-arm64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-arm64/-/html-darwin-arm64-1.12.6.tgz#dff9ee656cd1a4ac0a4a2637c4e9a058ce64b42a" + integrity sha512-McW4JsF5wFB5KmHyAaty94kw2hHLbYtrIQvVlshbXM3lpY+rDO0KnS74CcIiAD46p7knV0Y6Xuhint8K3rYfkg== -"@swc/html-darwin-x64@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.11.29.tgz#ccafed56081932ffaa51be1788cef88eb9a144d1" - integrity sha512-YfQPjh5WoDqOxsA7vDOOSnxEPc1Ki4SuZ0ufR4t8jYdMOFsU3AhZQ/sgBZLpTzegBTutUn7/7yy8VSoFngeR7Q== +"@swc/html-darwin-x64@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-darwin-x64/-/html-darwin-x64-1.12.6.tgz#194456c256cb5f949af24cfaf1740fb20098285a" + integrity sha512-Fh/bPNdnSNeJ7GrRAe/BqERWV9hbIyZktoMlvkMipz2NPTdadIwXjd8fscVDc6S5j1DigiSp2Mnf0rZgH6Xnhw== -"@swc/html-linux-arm-gnueabihf@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.11.29.tgz#e784a1a0f69034e9dd52a9019ff80f0d5eb91433" - integrity sha512-dC3aEv1mqAUkY9TiZWOE2IcYpvxJzw0LdvkDzGW5072JSlZZYQMqq2Llwg63LIp6qBlj1JLHMLnBqk7Ubatmjw== +"@swc/html-linux-arm-gnueabihf@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm-gnueabihf/-/html-linux-arm-gnueabihf-1.12.6.tgz#aeeeb4b27c06d0f813bbebd6ed48e471fb5873b8" + integrity sha512-F0Z2Fmvdw4vTmmJyFZaGMklrZkrtT9A5d8K1Ez2f7SZwhU09e2cgi49PCHL7wBfc5MBItnugdVJKYi/V6O/Jsg== -"@swc/html-linux-arm64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.11.29.tgz#ef015d81d3a011d6c273a428eee130b3aac790b7" - integrity sha512-seo+lCiBUggTR9NsHE4qVC+7+XIfLHK7yxWiIsXb8nNAXDcqVZ0Rxv8O1Y1GTeJfUlcCt1koahCG2AeyWpYFBg== +"@swc/html-linux-arm64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-gnu/-/html-linux-arm64-gnu-1.12.6.tgz#5aaf98ff9bced40c70a8662dde73db3a1bf21991" + integrity sha512-2S9hXG5EvDMHdjeiVANft+mZ+dRUrqUqKEAM0GehxsnG/ITT4uTolI3u/upMo7t1leOMWcz85hJZqDbVtfyP5Q== -"@swc/html-linux-arm64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.11.29.tgz#b20e4b442287367c4c1d62db2b8065106542f432" - integrity sha512-bK8K6t3hHgaZZ1vMNaZ+8x42EWJPEX1Dx4zi6ulMhKa1uan+DjW5SiMlUg0an16fFSYfE+r9oFC4cFEbGP1o4Q== +"@swc/html-linux-arm64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-arm64-musl/-/html-linux-arm64-musl-1.12.6.tgz#7f658b07b41ca7910d1af980988549d638666ebc" + integrity sha512-RqKvGk4V2HpEObFva1AbhhEpvH8VrRI1sRjHZW7I0kTWZZkg13tJXSmGhIAfUgJFGWvvVSoZ/8TSyRq8Ju6Pvw== -"@swc/html-linux-x64-gnu@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.11.29.tgz#c45505b3e22c02dc8bdef8b3da48ba855527a62c" - integrity sha512-34tSms5TkRUCr+J6uuSE/11ECcfIpp5R1ODuIgxZRUd/u88pQGKzLVNLWGPLw4b3cZSjnAn+PFJl7BtaYl0UyQ== +"@swc/html-linux-x64-gnu@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-gnu/-/html-linux-x64-gnu-1.12.6.tgz#b32b9f6fa65ef322d499e35ac0d1599ec425ad93" + integrity sha512-nZzjhrya4VFfT2jX2EYe+FF1EzeghHAB5wyOASFN35CxOpJMhr/04COu5uRggZGYD+19s1LrLelKhSOBAPDrOw== -"@swc/html-linux-x64-musl@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.11.29.tgz#86116e7db3cf02b1a627bc94c374d26ce6f9d68a" - integrity sha512-oJLLrX94ccaniWdQt8PH6K2u8aN/ehBo/YPg84LycFtaud/k73Fa1kh6Neq8vbWI4CugIWTl4LXWoHm+l+QYeA== +"@swc/html-linux-x64-musl@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-linux-x64-musl/-/html-linux-x64-musl-1.12.6.tgz#ac03730723528385f71b2a30b635002eea4f23e1" + integrity sha512-hJdSZw5lo+Ws355gs6M2cV4QTbRmc6Ide7kUYMoSQQFyZVc05am2sulPLfOTHmzV8BW3QZ3apO7wcKTzJ/yBbQ== -"@swc/html-win32-arm64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.11.29.tgz#cf7e57b8c0b52f7f93abc307b0cb78d8213b3c13" - integrity sha512-nw4TCFfA4YV6jicRdicJZPKW+ihOZPMKEG/4bj1/6HqXw1T2pXI070ASOLE0KOHYuoyV/jConEHfIjlU0olneA== +"@swc/html-win32-arm64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-arm64-msvc/-/html-win32-arm64-msvc-1.12.6.tgz#10fd59442feda8e3d0ce6dfccd3bef6fc1e01440" + integrity sha512-l7kFWXr4/A5joeJBSft8oGMVxXOORu6oKMSNk0SU9kFlSaqmQM9sFXW8Mny7P5bvJoNb/fGjnJ3o7BmSjwu3ow== -"@swc/html-win32-ia32-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.11.29.tgz#61c91409c3fcdf942891c02aa2a2eac1892d1907" - integrity sha512-rO6X4qOofGpKV8pyZ7VblJn+J3PHEqeWHJkJfzwP7c04Flr1oLyuLbTU8lwf8enXrTAZqitHZs+OpofKcUwHEw== +"@swc/html-win32-ia32-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-ia32-msvc/-/html-win32-ia32-msvc-1.12.6.tgz#aaa89f639059c1263855c571d6678ca8c5ce8e8a" + integrity sha512-4PQysHukXaGUbP9af6DdqEIuNHMShUj5xQrVZ9M/JNV77JuX8RhTTc8Nq4IzGvCepS77gJnKg2nUbKEOt0vHaQ== -"@swc/html-win32-x64-msvc@1.11.29": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.11.29.tgz#0e78b507d2bf28315487655852008cdecfe84535" - integrity sha512-GSCihzBItEPJAeLzkAtw0ZGbxRGMsGt1Z1ugo0uHva1R3Eybkqu9qoax1tGAON+EJzeiHRqphhNgh8MVDpnKnQ== +"@swc/html-win32-x64-msvc@1.12.6": + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html-win32-x64-msvc/-/html-win32-x64-msvc-1.12.6.tgz#632091341d39231c813fab1beabab4408d6ffe33" + integrity sha512-dNg1qIzriAUQkSwWQP+b7GK09zU126VYt9Eng4RlLzdvZYO1EWrnvTLGgvAADyLk8ELvrDUJ1joaaKzFzZXVOQ== "@swc/html@^1.7.39": - version "1.11.29" - resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.11.29.tgz#6e9e1b8ea65baa0d6f25cb883565a5e7d22d2858" - integrity sha512-Tsk/o6Eo3lDvHPGjLqVwXGEdC1bemGzByPWx/TrF5N7qEsanRblPeRcJzLl6LbWa80pRYIRB6T4VqdXXZqklaw== + version "1.12.6" + resolved "https://registry.yarnpkg.com/@swc/html/-/html-1.12.6.tgz#faf01ad0594287680bdb495b424da0f232f81216" + integrity sha512-Qki6Ci6f16BWJhEz5gNB/2QAsSIYvvIjLYUNsrmo1P//By7SF42oDZcu7jPLpsdlMK+qGH9n37be+HZFj9Zn5w== dependencies: "@swc/counter" "^0.1.3" optionalDependencies: - "@swc/html-darwin-arm64" "1.11.29" - "@swc/html-darwin-x64" "1.11.29" - "@swc/html-linux-arm-gnueabihf" "1.11.29" - "@swc/html-linux-arm64-gnu" "1.11.29" - "@swc/html-linux-arm64-musl" "1.11.29" - "@swc/html-linux-x64-gnu" "1.11.29" - "@swc/html-linux-x64-musl" "1.11.29" - "@swc/html-win32-arm64-msvc" "1.11.29" - "@swc/html-win32-ia32-msvc" "1.11.29" - "@swc/html-win32-x64-msvc" "1.11.29" + "@swc/html-darwin-arm64" "1.12.6" + "@swc/html-darwin-x64" "1.12.6" + "@swc/html-linux-arm-gnueabihf" "1.12.6" + "@swc/html-linux-arm64-gnu" "1.12.6" + "@swc/html-linux-arm64-musl" "1.12.6" + "@swc/html-linux-x64-gnu" "1.12.6" + "@swc/html-linux-x64-musl" "1.12.6" + "@swc/html-win32-arm64-msvc" "1.12.6" + "@swc/html-win32-ia32-msvc" "1.12.6" + "@swc/html-win32-x64-msvc" "1.12.6" -"@swc/types@^0.1.21": - version "0.1.21" - resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.21.tgz#6fcadbeca1d8bc89e1ab3de4948cef12344a38c0" - integrity sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ== +"@swc/types@^0.1.23": + version "0.1.23" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.23.tgz#7eabf88b9cfd929253859c562ae95982ee04b4e8" + integrity sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw== dependencies: "@swc/counter" "^0.1.3" @@ -3847,6 +4517,15 @@ dependencies: defer-to-connect "^2.0.1" +"@tanem/svg-injector@^10.1.68": + version "10.1.68" + resolved "https://registry.yarnpkg.com/@tanem/svg-injector/-/svg-injector-10.1.68.tgz#0bd08da3c4184b055a6fe16909037c96f49e3cd1" + integrity sha512-UkJajeR44u73ujtr5GVSbIlELDWD/mzjqWe54YMK61ljKxFcJoPd9RBSaO7xj02ISCWUqJW99GjrS+sVF0UnrA== + dependencies: + "@babel/runtime" "^7.23.2" + content-type "^1.0.5" + tslib "^2.6.2" + "@tanstack/react-virtual@^3.0.0-beta.60": version "3.5.1" resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.5.1.tgz#1ce466f530a10f781871360ed2bf7ff83e664f85" @@ -4147,9 +4826,9 @@ integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== "@types/estree@^1.0.6": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" - integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.33": version "4.19.3" @@ -4283,6 +4962,14 @@ resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== +"@types/node-fetch@^2.6.4": + version "2.6.12" + resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.12.tgz#8ab5c3ef8330f13100a7479e2cd56d3386830a03" + integrity sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA== + dependencies: + "@types/node" "*" + form-data "^4.0.0" + "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -4302,6 +4989,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== +"@types/node@^18.11.18": + version "18.19.86" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.86.tgz#a7e1785289c343155578b9d84a0e3e924deb948b" + integrity sha512-fifKayi175wLyKyc5qUfyENhQ1dCNI1UNjp653d8kuYcPQN5JhX3dGuP/XmvPTg/xRBn1VTLpbmi+H/Mr7tLfQ== + dependencies: + undici-types "~5.26.4" + "@types/parse5@^6.0.0": version "6.0.3" resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb" @@ -4317,6 +5011,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== +"@types/prop-types@^15.7.14": + version "15.7.14" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.14.tgz#1433419d73b2a7ebfc6918dcefd2ec0d5cd698f2" + integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== + "@types/qs@*": version "6.9.15" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.15.tgz#adde8a060ec9c305a82de1babc1056e73bd64dce" @@ -4712,6 +5411,87 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@zag-js/core@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/core/-/core-1.17.1.tgz#1d47e8117352cb42b3de0dd2672189a8e0bf955b" + integrity sha512-68jh6R87QLMYrtntu34eSF9JJXRXd+/l5Mpaz/InEOwA9sjxuyJIESqO578IpI2GAqk+cE1sUTKhhPmkzeTq3g== + dependencies: + "@zag-js/dom-query" "1.17.1" + "@zag-js/utils" "1.17.1" + +"@zag-js/dom-query@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/dom-query/-/dom-query-1.10.0.tgz#62d5cdb887297c7522bde3e86ddb67cedf1cfad2" + integrity sha512-UQM4pHPPwpPNyuIcaDvuTjI4ntvBCV0oatpd+OcOW8NdUc2VVcPzL4cN6q1h+Q9s0Rpi+q77X0x6t9c1QWj1Iw== + dependencies: + "@zag-js/types" "1.10.0" + +"@zag-js/dom-query@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/dom-query/-/dom-query-1.17.1.tgz#38a8496869fb4fd1e02b6734d5f59e52d17bfc71" + integrity sha512-fwwzEKLPq3kAZVkkPBdskL4Ge4aHRAGqBLfAHCKioQNgvKYGRTzqmGA6ijls9ESULUWf0M2ogKstuUtY19PopA== + dependencies: + "@zag-js/types" "1.17.1" + +"@zag-js/focus-trap@^1.7.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/focus-trap/-/focus-trap-1.10.0.tgz#c292010997ce09581aeb1729f9151a80aa4cf141" + integrity sha512-6+SPzXws7BurUb5AxHD6RoygInvPkGhleJmClQadeFhOlOdZdaeqwZjnoA3WoH/15V4NfUnoIzy72Su36D8RmA== + dependencies: + "@zag-js/dom-query" "1.10.0" + +"@zag-js/presence@^1.13.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/presence/-/presence-1.17.1.tgz#9fee698db453fa49c743a175910b2ba107a9bed5" + integrity sha512-2b9/4gs/ZuTpplqNjTARWjEgqkV8pMjcrH5u/fFng2cm5JRhcPrgWDSeOiahKOCdWj8x+f5EkNVvBOqs4Bmcsw== + dependencies: + "@zag-js/core" "1.17.1" + "@zag-js/dom-query" "1.17.1" + "@zag-js/types" "1.17.1" + +"@zag-js/react@^1.13.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/react/-/react-1.17.1.tgz#917f6fd739a9e54e73578f03813e2de96cc919c2" + integrity sha512-hgIpkHpfJByWMtaBvrJQNxBsEghFDWDWRx/JcG5cv+0VDS3bdT2U6b4AWRq6/6CMI1a2bXodgxXrgXj0t1UofQ== + dependencies: + "@zag-js/core" "1.17.1" + "@zag-js/store" "1.17.1" + "@zag-js/types" "1.17.1" + "@zag-js/utils" "1.17.1" + +"@zag-js/store@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/store/-/store-1.17.1.tgz#d7833045c56169f028324d64b8fd3dc2f78df22a" + integrity sha512-01iHhN08QezWTgouaAQdOW/WQUieTBv3Abl3QeGPtQ1UC8oygG84zea1uF+FzqxhT/KtWvI2AT0zRaw368aqVQ== + dependencies: + proxy-compare "3.0.1" + +"@zag-js/types@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@zag-js/types/-/types-1.10.0.tgz#d6f0d406d06cc954622b0234d2c2aeab64999ffd" + integrity sha512-HlM+EHYPLPaHgmuf2Bg5isNy2Kv30nwaANbkcMhVQYi8OfrTraxUQbTDXk3hb56qFmW1HQCMZzt1L7aS2qlOyQ== + dependencies: + csstype "3.1.3" + +"@zag-js/types@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/types/-/types-1.17.1.tgz#ce75409a9a89431f790038fd145cc9353d5fa236" + integrity sha512-KEPko1DK19hEMfM5IPKTZQtpf4HC3X56qwckezRX1yk+/vGotVUxdjRIrv+pcITjlFAoQQO9TiiZv2UiiVrFGA== + dependencies: + csstype "3.1.3" + +"@zag-js/utils@1.17.1": + version "1.17.1" + resolved "https://registry.yarnpkg.com/@zag-js/utils/-/utils-1.17.1.tgz#0015f9a160877672a75a2ed0419c289fb5fcb22d" + integrity sha512-+w/Kx7uZufg3cD6I5bQ8iSoeY3qSarPpUwrxz6FCOxJ86IAmf3ActqFC2pJ6DQCdHdkWINaKKchb4GNt8ld7KQ== + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -4743,9 +5523,9 @@ acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.7.1, acorn@^8.8.2: integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== acorn@^8.14.0: - version "8.14.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" - integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== address@^1.0.1: version "1.2.2" @@ -4759,6 +5539,13 @@ agent-base@6: dependencies: debug "4" +agentkeepalive@^4.2.1: + version "4.6.0" + resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== + dependencies: + humanize-ms "^1.2.1" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -4822,30 +5609,30 @@ ajv@^8.0.0, ajv@^8.9.0: uri-js "^4.4.1" algoliasearch-helper@^3.22.6: - version "3.25.0" - resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz#15cc79ad7909db66b8bb5a5a9c38b40e3941fa2f" - integrity sha512-vQoK43U6HXA9/euCqLjvyNdM4G2Fiu/VFp4ae0Gau9sZeIKBPvUPnXfLYAe65Bg7PFuw03coeu5K6lTPSXRObw== + version "3.26.0" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.26.0.tgz#d6e283396a9fc5bf944f365dc3b712570314363f" + integrity sha512-Rv2x3GXleQ3ygwhkhJubhhYGsICmShLAiqtUuJTUkr9uOCOXyF2E71LVT4XDnVffbknv8XgScP4U0Oxtgm+hIw== dependencies: "@algolia/events" "^4.0.1" algoliasearch@^5.14.2, algoliasearch@^5.17.1: - version "5.25.0" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.25.0.tgz#7337b097deadeca0e6e985c0f8724abea189994f" - integrity sha512-n73BVorL4HIwKlfJKb4SEzAYkR3Buwfwbh+MYxg2mloFph2fFGV58E90QTzdbfzWrLn4HE5Czx/WTjI8fcHaMg== + version "5.29.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.29.0.tgz#0feae8e0a71fced857be4e97c434ef9dce89783b" + integrity sha512-E2l6AlTWGznM2e7vEE6T6hzObvEyXukxMOlBmVlMyixZyK1umuO/CiVc6sDBbzVH0oEviCE5IfVY1oZBmccYPQ== dependencies: - "@algolia/client-abtesting" "5.25.0" - "@algolia/client-analytics" "5.25.0" - "@algolia/client-common" "5.25.0" - "@algolia/client-insights" "5.25.0" - "@algolia/client-personalization" "5.25.0" - "@algolia/client-query-suggestions" "5.25.0" - "@algolia/client-search" "5.25.0" - "@algolia/ingestion" "1.25.0" - "@algolia/monitoring" "1.25.0" - "@algolia/recommend" "5.25.0" - "@algolia/requester-browser-xhr" "5.25.0" - "@algolia/requester-fetch" "5.25.0" - "@algolia/requester-node-http" "5.25.0" + "@algolia/client-abtesting" "5.29.0" + "@algolia/client-analytics" "5.29.0" + "@algolia/client-common" "5.29.0" + "@algolia/client-insights" "5.29.0" + "@algolia/client-personalization" "5.29.0" + "@algolia/client-query-suggestions" "5.29.0" + "@algolia/client-search" "5.29.0" + "@algolia/ingestion" "1.29.0" + "@algolia/monitoring" "1.29.0" + "@algolia/recommend" "5.29.0" + "@algolia/requester-browser-xhr" "5.29.0" + "@algolia/requester-fetch" "5.29.0" + "@algolia/requester-node-http" "5.29.0" allof-merge@^0.6.6: version "0.6.6" @@ -4854,6 +5641,11 @@ allof-merge@^0.6.6: dependencies: json-crawl "^0.5.3" +altcha-lib@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/altcha-lib/-/altcha-lib-1.2.0.tgz#a8b874ace261751473686adc5cc210be7449ba0d" + integrity sha512-S5WF8QLNRaM1hvK24XPhOLfu9is2EBCvH7+nv50sM5CaIdUCqQCd0WV/qm/ZZFGTdSoKLuDp+IapZxBLvC+SNg== + ansi-align@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" @@ -4932,6 +5724,13 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-hidden@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/aria-hidden/-/aria-hidden-1.2.4.tgz#b78e383fdbc04d05762c78b4a25a501e736c4522" + integrity sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A== + dependencies: + tslib "^2.0.0" + array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" @@ -4957,6 +5756,11 @@ async@3.2.4: resolved "https://registry.yarnpkg.com/async/-/async-3.2.4.tgz#2d22e00f8cddeb5fde5dd33522b56d1cf569a81c" integrity sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + at-least-node@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" @@ -5312,10 +6116,10 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001599, caniuse-lite@^1.0.30001629: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz" integrity sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA== -caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001716, caniuse-lite@^1.0.30001718: - version "1.0.30001718" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz#dae13a9c80d517c30c6197515a96131c194d8f82" - integrity sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw== +caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001718: + version "1.0.30001724" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz#312e163553dd70d2c0fb603d74810c85d8ed94a0" + integrity sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA== ccount@^2.0.0: version "2.0.1" @@ -5455,6 +6259,13 @@ ci-info@^3.2.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +class-variance-authority@0.7.1: + version "0.7.1" + resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787" + integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg== + dependencies: + clsx "^2.1.1" + clean-css@^5.2.2, clean-css@^5.3.3, clean-css@~5.3.2: version "5.3.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.3.tgz#b330653cd3bd6b75009cc25c714cae7b93351ccd" @@ -5504,16 +6315,16 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clsx@2.1.1, clsx@^2.0.0, clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + clsx@^1.1.1, clsx@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== -clsx@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" - integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== - collapse-white-space@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-2.1.0.tgz#640257174f9f42c740b40f3b55ee752924feefca" @@ -5558,11 +6369,23 @@ colorette@^2.0.10: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +colorjs.io@0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/colorjs.io/-/colorjs.io-0.5.2.tgz#63b20139b007591ebc3359932bef84628eb3fcef" + integrity sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw== + combine-promises@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/combine-promises/-/combine-promises-1.2.0.tgz#5f2e68451862acf85761ded4d9e2af7769c2ca6a" integrity sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + comma-separated-tokens@^2.0.0: version "2.0.3" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" @@ -5698,7 +6521,7 @@ content-disposition@0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -5750,11 +6573,11 @@ core-js-compat@^3.31.0, core-js-compat@^3.36.1: browserslist "^4.23.0" core-js-compat@^3.40.0: - version "3.42.0" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.42.0.tgz#ce19c29706ee5806e26d3cb3c542d4cfc0ed51bb" - integrity sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ== + version "3.43.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.43.0.tgz#055587369c458795ef316f65e0aabb808fb15840" + integrity sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA== dependencies: - browserslist "^4.24.4" + browserslist "^4.25.0" core-js-pure@^3.30.2: version "3.37.1" @@ -5919,9 +6742,9 @@ css-what@^6.0.1, css-what@^6.1.0: integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== cssdb@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.3.0.tgz#940becad497b8509ad822a28fb0cfe54c969ccfe" - integrity sha512-c7bmItIg38DgGjSwDPZOYF/2o0QU/sSgkWOMyl8votOfgFuyiFKWPesmCGEsrGLxEA9uL540cp8LdaGEjUGsZQ== + version "8.3.1" + resolved "https://registry.yarnpkg.com/cssdb/-/cssdb-8.3.1.tgz#0ac96395b7092ffee14563e948cf43c2019b051e" + integrity sha512-XnDRQMXucLueX92yDe0LPKupXetWoFOgawr4O4X41l5TltgK2NVbJJVDnnOywDYfW1sTJ28AcXGKOqdRKwCcmQ== cssesc@^3.0.0: version "3.0.0" @@ -5997,7 +6820,7 @@ csso@^5.0.5: dependencies: css-tree "~2.2.0" -csstype@^3.0.2: +csstype@3.1.3, csstype@^3.0.2: version "3.1.3" resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== @@ -6404,6 +7227,11 @@ delaunator@5: dependencies: robust-predicates "^3.0.2" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -6414,7 +7242,7 @@ depd@~1.1.2: resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== -dequal@^2.0.0: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -6434,6 +7262,11 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8" integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== +detect-node-es@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" + integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== + detect-node@^2.0.4: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" @@ -6601,7 +7434,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.2.4: +dompurify@^3.2.5: version "3.2.6" resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad" integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ== @@ -6670,11 +7503,6 @@ electron-to-chromium@^1.4.796: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.803.tgz#cf55808a5ee12e2a2778bbe8cdc941ef87c2093b" integrity sha512-61H9mLzGOCLLVsnLiRzCbc63uldP0AniRYPV3hbGVtONA1pI7qSGILdbofR7A8TMbOypDocEAjH/e+9k1QIe3g== -electron-to-chromium@^1.5.149: - version "1.5.158" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.158.tgz#e5f01fc7fdf810d9d223e30593e0839c306276d4" - integrity sha512-9vcp2xHhkvraY6AHw2WMi+GDSLPX42qe2xjYaVoZqFRJiOcilVQFq9mZmpuHEQpzlgGDelKlV7ZiGcmMsc8WxQ== - electron-to-chromium@^1.5.160: version "1.5.172" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.172.tgz#fe1d99028d8d6321668d0f1fed61d99ac896259c" @@ -6726,9 +7554,9 @@ enhanced-resolve@^5.17.0: tapable "^2.2.0" enhanced-resolve@^5.17.1: - version "5.18.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" - integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== + version "5.18.2" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464" + integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -6779,6 +7607,16 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es6-promise@^3.2.1: version "3.3.1" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-3.3.1.tgz#a08cdde84ccdbf34d027a1451bc91d4bcd28a613" @@ -6955,6 +7793,11 @@ eval@^0.1.8: "@types/node" "*" require-like ">= 0.1.1" +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0, eventemitter3@^4.0.4: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -7023,9 +7866,9 @@ express@^4.17.3: vary "~1.1.2" exsolve@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.5.tgz#1f5b6b4fe82ad6b28a173ccb955a635d77859dcf" - integrity sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg== + version "1.0.7" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e" + integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw== extend-shallow@^2.0.1: version "2.0.1" @@ -7200,16 +8043,39 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" +form-data-encoder@1.7.2: + version "1.7.2" + resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz#1f1ae3dccf58ed4690b86d87e4f57c654fbab040" + integrity sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A== + form-data-encoder@^2.1.2: version "2.1.4" resolved "https://registry.yarnpkg.com/form-data-encoder/-/form-data-encoder-2.1.4.tgz#261ea35d2a70d48d30ec7a9603130fa5515e9cd5" integrity sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw== +form-data@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.2.tgz#35cabbdd30c3ce73deb2c42d3c8d3ed9ca51794c" + integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + mime-types "^2.1.12" + format@^0.2.0: version "0.2.2" resolved "https://registry.yarnpkg.com/format/-/format-0.2.2.tgz#d6170107e9efdc4ed30c9dc39016df942b5cb58b" integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== +formdata-node@^4.3.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/formdata-node/-/formdata-node-4.4.1.tgz#23f6a5cb9cb55315912cbec4ff7b0f59bbd191e2" + integrity sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ== + dependencies: + node-domexception "1.0.0" + web-streams-polyfill "4.0.0-beta.3" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" @@ -7290,7 +8156,7 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" -get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -7306,6 +8172,11 @@ get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: hasown "^2.0.2" math-intrinsics "^1.1.0" +get-nonce@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" + integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== + get-own-enumerable-property-symbols@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" @@ -7523,6 +8394,13 @@ has-symbols@^1.1.0: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== +has-tostringtag@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" + integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw== + dependencies: + has-symbols "^1.0.3" + has-yarn@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-3.0.0.tgz#c3c21e559730d1d3b57e28af1f30d06fac38147d" @@ -7821,6 +8699,11 @@ html-tags@^3.3.1: resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ== +html-url-attributes@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87" + integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== + html-void-elements@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-2.0.1.tgz#29459b8b05c200b6c5ee98743c41b979d577549f" @@ -7949,6 +8832,18 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +humanize-ms@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" + integrity sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ== + dependencies: + ms "^2.0.0" + +humps@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/humps/-/humps-2.0.1.tgz#dd02ea6081bd0568dc5d073184463957ba9ef9aa" + integrity sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g== + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -7989,9 +8884,9 @@ immer@^9.0.21: integrity sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA== immutable@^5.0.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.2.tgz#e8169476414505e5a4fa650107b65e1227d16d4b" - integrity sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ== + version "5.1.3" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.3.tgz#e6486694c8b76c37c063cca92399fa64098634d4" + integrity sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg== import-fresh@^3.3.0: version "3.3.0" @@ -8244,6 +9139,11 @@ is-typedarray@^1.0.0: resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== +is-what@^4.1.8: + version "4.1.16" + resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" + integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== + is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" @@ -8715,6 +9615,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lucide-react@^0.503.0: + version "0.503.0" + resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.503.0.tgz#4ac55b262fa613f9497531c9df50ea0e883d2de2" + integrity sha512-HGGkdlPWQ0vTF8jJ5TdIqhQXZi6uh3LnNgfZ8MHiuxFfX3RZeA79r2MW2tHAZKlAVfoNE8esm3p+O6VkIvpj6w== + markdown-extensions@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-2.0.0.tgz#34bebc83e9938cae16e0e017e4a9814a8330d3c4" @@ -8732,7 +9637,7 @@ markdown-table@^3.0.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.3.tgz#e6331d30e493127e031dd385488b5bd326e4a6bd" integrity sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw== -marked@^15.0.7: +marked@^15.0.7, marked@^15.0.9: version "15.0.12" resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.12.tgz#30722c7346e12d0a2d0207ab9b0c4f0102d86c4e" integrity sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA== @@ -9194,6 +10099,13 @@ memoize-one@^5.1.1: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== +merge-anything@5.1.7: + version "5.1.7" + resolved "https://registry.yarnpkg.com/merge-anything/-/merge-anything-5.1.7.tgz#94f364d2b0cf21ac76067b5120e429353b3525d7" + integrity sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ== + dependencies: + is-what "^4.1.8" + merge-descriptors@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" @@ -9210,13 +10122,13 @@ merge2@^1.3.0, merge2@^1.4.1: integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== mermaid@>=11.6.0: - version "11.6.0" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.6.0.tgz#eee45cdc3087be561a19faf01745596d946bb575" - integrity sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg== + version "11.7.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.7.0.tgz#53f319147632db15e499c5ccb72b24b277a00bae" + integrity sha512-/1/5R0rt0Z1Ak0CuznAnCF3HtQgayRXUz6SguzOwN4L+DuCobz0UxnQ+ZdTSZ3AugKVVh78tiVmsHpHWV25TCw== dependencies: "@braintree/sanitize-url" "^7.0.4" "@iconify/utils" "^2.1.33" - "@mermaid-js/parser" "^0.4.0" + "@mermaid-js/parser" "^0.5.0" "@types/d3" "^7.4.3" cytoscape "^3.29.3" cytoscape-cose-bilkent "^4.1.0" @@ -9225,7 +10137,7 @@ mermaid@>=11.6.0: d3-sankey "^0.12.3" dagre-d3-es "7.0.11" dayjs "^1.11.13" - dompurify "^3.2.4" + dompurify "^3.2.5" katex "^0.16.9" khroma "^2.1.0" lodash-es "^4.17.21" @@ -10041,7 +10953,7 @@ mime-types@2.1.31: dependencies: mime-db "1.48.0" -mime-types@2.1.35, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@2.1.35, mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -10147,7 +11059,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.3: +ms@2.1.3, ms@^2.0.0, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -10207,6 +11119,11 @@ node-addon-api@^7.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== +node-domexception@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" + integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ== + node-emoji@^2.1.0: version "2.1.3" resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-2.1.3.tgz#93cfabb5cc7c3653aa52f29d6ffb7927d8047c06" @@ -10231,7 +11148,7 @@ node-fetch@2.6.7: dependencies: whatwg-url "^5.0.0" -node-fetch@^2.6.1: +node-fetch@^2.6.1, node-fetch@^2.6.7: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -10435,6 +11352,19 @@ open@^8.0.9, open@^8.4.0: is-docker "^2.1.1" is-wsl "^2.2.0" +openai@4.78.1: + version "4.78.1" + resolved "https://registry.yarnpkg.com/openai/-/openai-4.78.1.tgz#44c3b195d239891be9c9c53722539ad8a1fcc5f2" + integrity sha512-drt0lHZBd2lMyORckOXFPQTmnGLWSLt8VK0W9BhOKWpMFBEoHMoz5gxMPmVq5icp+sOrsbMnsmZTVHUlKvD1Ow== + dependencies: + "@types/node" "^18.11.18" + "@types/node-fetch" "^2.6.4" + abort-controller "^3.0.0" + agentkeepalive "^4.2.1" + form-data-encoder "1.7.2" + formdata-node "^4.3.2" + node-fetch "^2.6.7" + openapi-to-postmanv2@^4.21.0: version "4.21.0" resolved "https://registry.yarnpkg.com/openapi-to-postmanv2/-/openapi-to-postmanv2-4.21.0.tgz#4bc5b19ccbd1514c2b3466268a7f5dd64b61f535" @@ -10690,7 +11620,7 @@ path-type@^4.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -path@0.12.7: +path@0.12.7, path@^0.12.7: version "0.12.7" resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f" integrity sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q== @@ -11471,6 +12401,14 @@ pretty-time@^1.1.0: resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e" integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA== +prism-react-renderer@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz#ac63b7f78e56c8f2b5e76e823a976d5ede77e35f" + integrity sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig== + dependencies: + "@types/prismjs" "^1.26.0" + clsx "^2.0.0" + prism-react-renderer@^2.0.6, prism-react-renderer@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.3.1.tgz#e59e5450052ede17488f6bc85de1553f584ff8d5" @@ -11534,6 +12472,11 @@ proxy-addr@~2.0.7: forwarded "0.2.0" ipaddr.js "1.9.1" +proxy-compare@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/proxy-compare/-/proxy-compare-3.0.1.tgz#3262cff3a25a6dedeaa299f6cf2369d6f7588a94" + integrity sha512-V9plBAt3qjMlS1+nC8771KNf6oJ12gExvaxnNzN/9yVRLdTv/lc+oJlnSzrdYDAvBfTStPCoiaCOTmTs0adv7Q== + proxy-from-env@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -11680,6 +12623,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-error-boundary@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.0.0.tgz#a9e552146958fa77d873b587aa6a5e97544ee954" + integrity sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA== + dependencies: + "@babel/runtime" "^7.12.5" + react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: version "3.2.2" resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" @@ -11701,6 +12651,11 @@ react-helmet-async@^1.3.0, "react-helmet-async@npm:@slorber/react-helmet-async@1 react-fast-compare "^3.2.0" shallowequal "^1.1.0" +react-hook-form@7.54.2: + version "7.54.2" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.54.2.tgz#8c26ed54c71628dff57ccd3c074b1dd377cfb211" + integrity sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg== + react-hook-form@^7.43.8: version "7.52.0" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.52.0.tgz#e52b33043e283719586b9dd80f6d51b68dd3999c" @@ -11759,6 +12714,22 @@ react-magic-dropzone@^1.0.1: resolved "https://registry.yarnpkg.com/react-magic-dropzone/-/react-magic-dropzone-1.0.1.tgz#bfd25b77b57e7a04aaef0a28910563b707ee54df" integrity sha512-0BIROPARmXHpk4AS3eWBOsewxoM5ndk2psYP/JmbCq8tz3uR2LIV1XiroZ9PKrmDRMctpW+TvsBCtWasuS8vFA== +react-markdown@9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.3.tgz#c12bf60dad05e9bf650b86bcc612d80636e8456e" + integrity sha512-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-markdown@^8.0.1: version "8.0.7" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-8.0.7.tgz#c8dbd1b9ba5f1c5e7e5f2a44de465a3caafdf89b" @@ -11813,6 +12784,36 @@ react-redux@^7.2.0: prop-types "^15.7.2" react-is "^17.0.2" +react-remove-scroll-bar@^2.3.7: + version "2.3.8" + resolved "https://registry.yarnpkg.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz#99c20f908ee467b385b68a3469b4a3e750012223" + integrity sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q== + dependencies: + react-style-singleton "^2.2.2" + tslib "^2.0.0" + +react-remove-scroll@^2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz#df02cde56d5f2731e058531f8ffd7f9adec91ac2" + integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + +react-remove-scroll@^2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz#d2101d414f6d81d7d3bf033f3c1cb4785789f753" + integrity sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA== + dependencies: + react-remove-scroll-bar "^2.3.7" + react-style-singleton "^2.2.3" + tslib "^2.1.0" + use-callback-ref "^1.3.3" + use-sidecar "^1.1.3" + react-router-config@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" @@ -11848,6 +12849,33 @@ react-router@5.3.4, react-router@^5.3.4: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" + integrity sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ== + dependencies: + get-nonce "^1.0.0" + tslib "^2.0.0" + +react-svg@16.3.0: + version "16.3.0" + resolved "https://registry.yarnpkg.com/react-svg/-/react-svg-16.3.0.tgz#de7a4bb6ee2d465c1ff7125ec27414ac27e907d7" + integrity sha512-MvoQbITgkmpPJYwDTNdiUyoncJFfoa0D86WzoZuMQ9c/ORJURPR6rPMnXDsLOWDCAyXuV9nKZhQhGyP0HZ0MVQ== + dependencies: + "@babel/runtime" "^7.26.0" + "@tanem/svg-injector" "^10.1.68" + "@types/prop-types" "^15.7.14" + prop-types "^15.8.1" + +react-textarea-autosize@8.5.7: + version "8.5.7" + resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.5.7.tgz#b2bf1913383a05ffef7fbc89c2ea21ba8133b023" + integrity sha512-2MqJ3p0Jh69yt9ktFIaZmORHXw4c4bxSIhCeWiFwmJ9EYKgLmuNII3e9c9b2UO+ijl4StnpZdqpxNIhTdHvqtQ== + dependencies: + "@babel/runtime" "^7.20.13" + use-composed-ref "^1.3.0" + use-latest "^1.2.1" + react@^18.2.0: version "18.3.1" resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" @@ -12008,6 +13036,15 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" +rehype-raw@7.0.0, rehype-raw@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" + integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== + dependencies: + "@types/hast" "^3.0.0" + hast-util-raw "^9.0.0" + vfile "^6.0.0" + rehype-raw@^6.1.1: version "6.1.1" resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-6.1.1.tgz#81bbef3793bd7abacc6bf8335879d1b6c868c9d4" @@ -12017,15 +13054,6 @@ rehype-raw@^6.1.1: hast-util-raw "^7.2.0" unified "^10.0.0" -rehype-raw@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" - integrity sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww== - dependencies: - "@types/hast" "^3.0.0" - hast-util-raw "^9.0.0" - vfile "^6.0.0" - relateurl@^0.2.7: version "0.2.7" resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" @@ -12084,6 +13112,18 @@ remark-gfm@^4.0.0: remark-stringify "^11.0.0" unified "^11.0.0" +remark-gfm@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-gfm "^3.0.0" + micromark-extension-gfm "^3.0.0" + remark-parse "^11.0.0" + remark-stringify "^11.0.0" + unified "^11.0.0" + remark-mdx@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/remark-mdx/-/remark-mdx-3.0.1.tgz#8f73dd635c1874e44426e243f72c0977cf60e212" @@ -12304,9 +13344,9 @@ sass-loader@^16.0.2: neo-async "^2.6.2" sass@^1.80.4: - version "1.89.0" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.0.tgz#6df72360c5c3ec2a9833c49adafe57b28206752d" - integrity sha512-ld+kQU8YTdGNjOLfRWBzewJpU5cwEv/h5yyqlSeJcj6Yh8U4TDA9UA5FPicqDz/xgRPWRSYIQNiFks21TbA9KQ== + version "1.89.2" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e" + integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA== dependencies: chokidar "^4.0.0" immutable "^5.0.2" @@ -12994,6 +14034,11 @@ swc-loader@^0.2.6: dependencies: "@swc/counter" "^0.1.3" +tailwind-merge@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-2.6.0.tgz#ac5fb7e227910c038d458f396b7400d93a3142d5" + integrity sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA== + tailwindcss@^3.2.4: version "3.4.4" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.4.tgz#351d932273e6abfa75ce7d226b5bf3a6cb257c05" @@ -13081,9 +14126,9 @@ terser@^5.10.0, terser@^5.15.1, terser@^5.26.0: source-map-support "~0.5.20" terser@^5.31.1: - version "5.40.0" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.40.0.tgz#839a80db42bfee8340085f44ea99b5cba36c55c8" - integrity sha512-cfeKl/jjwSR5ar7d0FGmave9hFGJT8obyo0z+CrQOylLDbk7X81nPU6vq9VORa5jU30SkDnT2FXjLbR8HLP+xA== + version "5.43.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.43.1.tgz#88387f4f9794ff1a29e7ad61fb2932e25b4fdb6d" + integrity sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.14.0" @@ -13130,9 +14175,9 @@ tinyexec@^1.0.1: integrity sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw== tinypool@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.2.tgz#706193cc532f4c100f66aa00b01c42173d9051b2" - integrity sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA== + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== to-fast-properties@^2.0.0: version "2.0.0" @@ -13191,7 +14236,12 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== -tslib@^2.0.3, tslib@^2.6.0: +tslib@^2.0.0, tslib@^2.6.2: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +tslib@^2.0.3, tslib@^2.1.0, tslib@^2.6.0: version "2.6.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== @@ -13486,11 +14536,48 @@ url@^0.11.1: punycode "^1.4.1" qs "^6.12.3" +use-callback-ref@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz#98d9fab067075841c5b2c6852090d5d0feabe2bf" + integrity sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg== + dependencies: + tslib "^2.0.0" + +use-composed-ref@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.4.0.tgz#09e023bf798d005286ad85cd20674bdf5770653b" + integrity sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w== + use-editable@^2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/use-editable/-/use-editable-2.3.3.tgz#a292fe9ba4c291cd28d1cc2728c75a5fc8d9a33f" integrity sha512-7wVD2JbfAFJ3DK0vITvXBdpd9JAz5BcKAAolsnLBuBn6UDDwBGuCIAGvR3yA2BNKm578vAMVHFCWaOcA+BhhiA== +use-isomorphic-layout-effect@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz#afb292eb284c39219e8cb8d3d62d71999361a21d" + integrity sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w== + +use-latest@^1.2.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.3.0.tgz#549b9b0d4c1761862072f0899c6f096eb379137a" + integrity sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ== + dependencies: + use-isomorphic-layout-effect "^1.1.1" + +use-sidecar@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/use-sidecar/-/use-sidecar-1.1.3.tgz#10e7fd897d130b896e2c546c63a5e8233d00efdb" + integrity sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ== + dependencies: + detect-node-es "^1.1.0" + tslib "^2.0.0" + +use-sync-external-store@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz#55122e2a3edd2a6c106174c27485e0fd59bcfca0" + integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -13699,6 +14786,11 @@ web-namespaces@^2.0.0: resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-2.0.1.tgz#1010ff7c650eccb2592cebeeaf9a1b253fd40692" integrity sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ== +web-streams-polyfill@4.0.0-beta.3: + version "4.0.0-beta.3" + resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz#2898486b74f5156095e473efe989dcf185047a38" + integrity sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug== + webidl-conversions@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" From 4cd52f33ebb8d3fb8a2f7e14bc9d8aeafbbdd976 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Mon, 30 Jun 2025 08:48:04 +0300 Subject: [PATCH 109/123] chore(oidc): remove feature flag for introspection triggers (#10132) # Which Problems Are Solved Remove the feature flag that allowed triggers in introspection. This option was a fallback in case introspection would not function properly without triggers. The API documentation asked for anyone using this flag to raise an issue. No such issue was received, hence we concluded it is safe to remove it. # How the Problems Are Solved - Remove flags from the system and instance level feature APIs. - Remove trigger functions that are no longer used - Adjust tests that used the flag. # Additional Changes - none # Additional Context - Closes #10026 - Flag was introduced in #7356 --------- Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- cmd/setup/config_test.go | 7 +- cmd/start/config_test.go | 7 +- .../components/features/features.component.ts | 1 - console/src/assets/i18n/bg.json | 2 - console/src/assets/i18n/cs.json | 2 - console/src/assets/i18n/de.json | 2 - console/src/assets/i18n/en.json | 2 - console/src/assets/i18n/es.json | 2 - console/src/assets/i18n/fr.json | 2 - console/src/assets/i18n/hu.json | 2 - console/src/assets/i18n/id.json | 2 - console/src/assets/i18n/it.json | 2 - console/src/assets/i18n/ja.json | 2 - console/src/assets/i18n/ko.json | 2 - console/src/assets/i18n/mk.json | 2 - console/src/assets/i18n/nl.json | 2 - console/src/assets/i18n/pl.json | 2 - console/src/assets/i18n/pt.json | 2 - console/src/assets/i18n/ro.json | 2 - console/src/assets/i18n/ru.json | 2 - console/src/assets/i18n/sv.json | 2 - console/src/assets/i18n/zh.json | 2 - internal/api/grpc/feature/v2/converter.go | 88 +++++++++---------- .../api/grpc/feature/v2/converter_test.go | 68 +++++--------- .../v2/integration_test/feature_test.go | 33 ++----- internal/api/grpc/feature/v2beta/converter.go | 52 +++++------ .../api/grpc/feature/v2beta/converter_test.go | 60 +++++-------- .../v2beta/integration_test/feature_test.go | 22 +---- internal/api/oidc/access_token.go | 3 +- .../oidc/integration_test/userinfo_test.go | 37 -------- internal/api/oidc/introspect.go | 6 -- internal/api/oidc/userinfo.go | 6 -- internal/command/instance_features.go | 24 +++-- internal/command/instance_features_model.go | 5 -- internal/command/instance_features_test.go | 42 ++------- internal/command/system_features.go | 20 ++--- internal/command/system_features_model.go | 5 -- internal/command/system_features_test.go | 44 ++-------- internal/feature/feature.go | 50 +++++------ internal/feature/feature_test.go | 1 - internal/feature/key_enumer.go | 14 ++- internal/query/instance_features.go | 25 +++--- internal/query/instance_features_model.go | 4 - internal/query/instance_features_test.go | 36 -------- internal/query/introspection.go | 6 -- .../query/projection/instance_features.go | 4 - internal/query/projection/system_features.go | 4 - internal/query/system_features.go | 19 ++-- internal/query/system_features_model.go | 3 - internal/query/system_features_test.go | 32 ------- internal/query/userinfo.go | 6 -- .../feature/feature_v2/eventstore.go | 2 - .../repository/feature/feature_v2/feature.go | 46 +++++----- proto/zitadel/feature/v2/instance.proto | 21 +---- proto/zitadel/feature/v2/system.proto | 22 +---- proto/zitadel/feature/v2beta/instance.proto | 21 +---- proto/zitadel/feature/v2beta/system.proto | 22 +---- 57 files changed, 247 insertions(+), 659 deletions(-) diff --git a/cmd/setup/config_test.go b/cmd/setup/config_test.go index b147ed54a7..6c087fe402 100644 --- a/cmd/setup/config_test.go +++ b/cmd/setup/config_test.go @@ -36,8 +36,6 @@ func TestMustNewConfig(t *testing.T) { DefaultInstance: Features: LoginDefaultOrg: true - LegacyIntrospection: true - TriggerIntrospectionProjections: true UserSchema: true Log: Level: info @@ -47,9 +45,8 @@ Actions: `}, want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(true), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) }, }, { diff --git a/cmd/start/config_test.go b/cmd/start/config_test.go index 918fa51950..3c8328e557 100644 --- a/cmd/start/config_test.go +++ b/cmd/start/config_test.go @@ -73,8 +73,6 @@ Log: DefaultInstance: Features: LoginDefaultOrg: true - LegacyIntrospection: true - TriggerIntrospectionProjections: true UserSchema: true Log: Level: info @@ -84,9 +82,8 @@ Actions: `}, want: func(t *testing.T, config *Config) { assert.Equal(t, config.DefaultInstance.Features, &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(true), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) }, }, { diff --git a/console/src/app/components/features/features.component.ts b/console/src/app/components/features/features.component.ts index ace2788fcf..8e8c0f9106 100644 --- a/console/src/app/components/features/features.component.ts +++ b/console/src/app/components/features/features.component.ts @@ -35,7 +35,6 @@ const FEATURE_KEYS = [ 'loginDefaultOrg', 'oidcSingleV1SessionTermination', 'oidcTokenExchange', - 'oidcTriggerIntrospectionProjections', 'permissionCheckV2', 'userSchema', ] as const; diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 50c0d66027..2d51fa2571 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1625,8 +1625,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Потребителският интерфейс за влизане ще използва настройките на организацията по подразбиране (а не на инстанцията), ако не е зададен контекст на организация.", "OIDCTOKENEXCHANGE": "Обмяна на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Активиране на експерименталния тип на дарение urn:ietf:params:oauth:grant-type:token-exchange за краен пункт на токен OIDC. Обменът на токени може да се използва за заявка на токени с по-малък обхват или за имперсонализиране на други потребители. Вижте политиката за сигурност, за да разрешите имперсонализацията на инстанция.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Тригери за проекции на осмисляне на OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Активиране на тригери за проекции по време на заявка за осмисляне. Това може да действа като обходен механизъм, ако има забележими проблеми с консистентността в отговора на осмислянето, но може да окаже влияние върху производителността. Планираме да премахнем тригерите за заявки за осмисляне в бъдеще.", "USERSCHEMA": "Потребителска схема", "USERSCHEMA_DESCRIPTION": "Потребителските схеми позволяват управление на данните за схемите на потребителите. Ако е активиран флагът, ще можете да използвате новото API и неговите функции.", "ACTIONS": "Действия", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index 5b4547ccb4..cfa342f548 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Přihlašovací rozhraní použije nastavení výchozí organizace (a ne z instance), pokud není nastaven žádný kontext organizace.", "OIDCTOKENEXCHANGE": "Výměna tokenů OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Povolit experimentální typ udělení urn:ietf:params:oauth:grant-type:token-exchange pro bod tokenového bodu OIDC. Výměna tokenů lze použít k žádosti o tokeny s menším rozsahem nebo k impersonaci jiných uživatelů. Podívejte se na bezpečnostní politiku, abyste umožnili impersonaci na instanci.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Spouštěče projekcí introspekce OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Povolit spouštěče projekcí během požadavku na introspekci. To může sloužit jako obcházení, pokud existují zjevné problémy s konzistencí v odpovědi na introspekci, ale může to mít vliv na výkon. Plánujeme odstranit spouštěče pro požadavky na introspekci v budoucnosti.", "USERSCHEMA": "Schéma uživatele", "USERSCHEMA_DESCRIPTION": "Schémata uživatelů umožňují spravovat datová schémata uživatelů. Pokud je příznak povolen, budete moci používat nové API a jeho funkce.", "ACTIONS": "Akce", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 8fec6498ec..ad278c5863 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Die Anmelde-Benutzeroberfläche verwendet die Einstellungen der Standardorganisation (und nicht von der Instanz), wenn kein Organisationskontext festgelegt ist.", "OIDCTOKENEXCHANGE": "OIDC Token-Austausch", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivieren Sie den experimentellen urn:ietf:params:oauth:grant-type:token-exchange-Grant-Typ für den OIDC-Token-Endpunkt. Der Token-Austausch kann verwendet werden, um Token mit einem geringeren Umfang anzufordern oder andere Benutzer zu impersonieren. Siehe die Sicherheitsrichtlinie, um die Impersonation auf einer Instanz zu erlauben.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger-Introspektionsprojektionen", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktivieren Sie Projektionstrigger während einer Introspektionsanfrage. Dies kann als Workaround fungieren, wenn bemerkbare Konsistenzprobleme in der Introspektionsantwort auftreten, kann sich jedoch auf die Leistung auswirken. Wir planen, Trigger für Introspektionsanfragen in Zukunft zu entfernen.", "USERSCHEMA": "Benutzerschema", "USERSCHEMA_DESCRIPTION": "Benutzerschemata ermöglichen das Verwalten von Datenschemata von Benutzern. Wenn die Flagge aktiviert ist, können Sie die neue API und ihre Funktionen verwenden.", "ACTIONS": "Aktionen", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 95fd55bfef..6e584f9336 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1629,8 +1629,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "The login UI will use the settings of the default org (and not from the instance) if no organization context is set", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Enable the experimental urn:ietf:params:oauth:grant-type:token-exchange grant type for the OIDC token endpoint. Token exchange can be used to request tokens with a lesser scope or impersonate other users. See the security policy to allow impersonation on an instance.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future.", "USERSCHEMA": "User Schema", "USERSCHEMA_DESCRIPTION": "User Schemas allow to manage data schemas of user. If the flag is enabled, you'll be able to use the new API and its features.", "ACTIONS": "Actions", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 359aa4a0b5..55007b3086 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1627,8 +1627,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "La interfaz de inicio de sesión utilizará la configuración de la organización predeterminada (y no de la instancia) si no se establece ningún contexto de organización.", "OIDCTOKENEXCHANGE": "Intercambio de tokens OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita el tipo de concesión experimental urn:ietf:params:oauth:grant-type:token-exchange para el punto de extremo de token OIDC. El intercambio de tokens se puede utilizar para solicitar tokens con un alcance menor o suplantar a otros usuarios. Consulta la política de seguridad para permitir la suplantación en una instancia.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Desencadenadores de proyecciones de introspección OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Habilita los desencadenadores de proyección durante una solicitud de introspección. Esto puede actuar como un mecanismo alternativo si hay problemas de coherencia perceptibles en la respuesta a la introspección, pero puede afectar al rendimiento. Estamos planeando eliminar los desencadenadores para las solicitudes de introspección en el futuro.", "USERSCHEMA": "Esquema de usuario", "USERSCHEMA_DESCRIPTION": "Los esquemas de usuario permiten gestionar los esquemas de datos de los usuarios. Si se activa la bandera, podrás utilizar la nueva API y sus funciones.", "ACTIONS": "Acciones", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 0864a2f8c0..cda6a044ff 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "L'interface de connexion utilisera les paramètres de l'organisation par défaut (et non de l'instance) si aucun contexte d'organisation n'est défini.", "OIDCTOKENEXCHANGE": "Échange de jetons OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activez le type d'octroi expérimental urn:ietf:params:oauth:grant-type:token-exchange pour le point de terminaison de jeton OIDC. L'échange de jetons peut être utilisé pour demander des jetons avec une portée moindre ou pour usurper d'autres utilisateurs. Consultez la politique de sécurité pour autoriser l'usurpation sur une instance.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Déclencheurs de projections d'introspection OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Activez les déclencheurs de projection lors d'une demande d'introspection. Cela peut agir comme un contournement s'il existe des problèmes de cohérence perceptibles dans la réponse à l'introspection, mais cela peut avoir un impact sur les performances. Nous prévoyons de supprimer les déclencheurs pour les demandes d'introspection à l'avenir.", "USERSCHEMA": "Schéma utilisateur", "USERSCHEMA_DESCRIPTION": "Les schémas utilisateur permettent de gérer les schémas de données des utilisateurs. Si le drapeau est activé, vous pourrez utiliser la nouvelle API et ses fonctionnalités.", "ACTIONS": "Actions", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index a87122dc52..133d183355 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1624,8 +1624,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "A bejelentkezési felület az alapértelmezett org beállításait fogja használni (és nem az instance-tól), ha nincs megadva szervezeti kontextus", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Engedélyezd a kísérleti urn:ietf:params:oauth:grant-type:token-exchange támogatását az OIDC token végpont számára. A token csere használható kisebb hatókörű tokenek kérésére vagy más felhasználók megszemélyesítésére. Tekintsd meg a biztonsági irányelvet az impersonáció engedélyezéséhez egy példányon.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Introspekciós Projekciók Indítása", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Engedélyezd a projekciós indítókat az introspekciós kérés során. Ez lehet egy megoldás, ha észrevehető konzisztenciaproblémák vannak az introspekciós válaszban, de hatással lehet a teljesítményre. Tervezzük, hogy a jövőben eltávolítjuk a triggereket az introspekciós kérésből.", "USERSCHEMA": "Felhasználói Séma", "USERSCHEMA_DESCRIPTION": "A Felhasználói Sémák lehetővé teszik a felhasználói adat sémák kezelését. Ha az opció engedélyezve van, használhatod az új API-t és annak funkcióit.", "ACTIONS": "Műveletek", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 3f245d03c5..e494beeeca 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1500,8 +1500,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "UI login akan menggunakan pengaturan organisasi default (dan bukan dari instance) jika tidak ada konteks organisasi yang ditetapkan", "OIDCTOKENEXCHANGE": "Pertukaran Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktifkan jenis pemberian urn:ietf:params:oauth:grant-type:token-exchange eksperimental untuk titik akhir token OIDC. Pertukaran token dapat digunakan untuk meminta token dengan cakupan yang lebih kecil atau menyamar sebagai pengguna lain. Lihat kebijakan keamanan untuk mengizinkan peniruan identitas pada sebuah instans.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Memicu Proyeksi Introspeksi", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktifkan pemicu proyeksi selama permintaan introspeksi. Hal ini dapat menjadi solusi jika terdapat masalah konsistensi yang nyata dalam respons introspeksi namun dapat berdampak pada kinerja. Kami berencana untuk menghilangkan pemicu permintaan introspeksi di masa depan.", "USERSCHEMA": "Skema Pengguna", "USERSCHEMA_DESCRIPTION": "Skema Pengguna memungkinkan untuk mengelola skema data pengguna. Jika tanda ini diaktifkan, Anda akan dapat menggunakan API baru dan fitur-fiturnya.", "ACTIONS": "Tindakan", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index e127281433..fa009cac77 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "L'interfaccia di accesso utilizzerà le impostazioni dell'organizzazione predefinita (e non dell'istanza) se non è impostato alcun contesto organizzativo.", "OIDCTOKENEXCHANGE": "Scambio token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Abilita il tipo di concessione sperimentale urn:ietf:params:oauth:grant-type:token-exchange per il punto finale del token OIDC. Lo scambio di token può essere utilizzato per richiedere token con uno scopo inferiore o impersonare altri utenti. Consultare la policy di sicurezza per consentire l'impersonificazione su un'istanza.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiezioni trigger OIDC per l'introspezione", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Abilita i trigger di proiezione durante una richiesta di introspezione. Questo può agire come soluzione alternativa se ci sono problemi di coerenza evidenti nella risposta all'introspezione, ma può influire sulle prestazioni. Stiamo pianificando di rimuovere i trigger per le richieste di introspezione in futuro.", "USERSCHEMA": "Schema utente", "USERSCHEMA_DESCRIPTION": "Gli schemi utente consentono di gestire gli schemi di dati degli utenti. Se la flag è attivata, sarà possibile utilizzare la nuova API e le sue funzionalità.", "ACTIONS": "Azioni", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 250561e938..dc5dfd4a34 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "組織コンテキストが設定されていない場合、ログイン UI は既定の組織の設定を使用します (インスタンスの設定ではなく)", "OIDCTOKENEXCHANGE": "OIDC トークン交換", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC トークン エンドポイント用に実験的な urn:ietf:params:oauth:grant-type:token-exchange 付与タイプを有効にします。トークン交換は、より少ないスコープを持つトークンを要求するか、他のユーザーになりすますために使用できます。インスタンスでのなりすましを許可するには、セキュリティポリシーを参照してください。", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC トリガーイントロスペクションプロジェクション", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "イントロスペクション要求中にプロジェクショントリガーを有効にします。これは、イントロスペクションレスポンスに顕著な整合性問題がある場合の回避策として機能しますが、パフォーマンスに影響を与える可能性があります。今後、イントロスペクション要求のトリガーを削除する予定です。", "USERSCHEMA": "ユーザー スキーマ", "USERSCHEMA_DESCRIPTION": "ユーザー スキーマを使用すると、ユーザーのデータスキーマを管理できます。フラグが有効になっている場合、新しい APIとその機能を使用できます。", "ACTIONS": "アクション", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index 716375941d..eaf9968a66 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "조직 컨텍스트가 설정되지 않은 경우 로그인 UI가 기본 조직의 설정을 사용합니다 (인스턴스에서 설정되지 않음).", "OIDCTOKENEXCHANGE": "OIDC 토큰 교환", "OIDCTOKENEXCHANGE_DESCRIPTION": "OIDC 토큰 엔드포인트의 실험적 urn:ietf:params:oauth:grant-type:token-exchange 허용을 활성화합니다. 토큰 교환을 통해 범위가 좁은 토큰을 요청하거나 다른 사용자를 가장할 수 있습니다. 인스턴스에서 가장을 허용하는 보안 정책을 확인하세요.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 트리거 내부 조사 프로젝션", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "내부 조사 요청 중 프로젝션 트리거를 활성화합니다. 이는 내부 조사 응답에서 일관성 문제가 있는 경우 임시 해결책으로 작동할 수 있으나 성능에 영향을 미칠 수 있습니다. 향후 내부 조사 요청에 대한 트리거 제거를 계획 중입니다.", "USERSCHEMA": "사용자 스키마", "USERSCHEMA_DESCRIPTION": "사용자 스키마를 통해 사용자의 데이터 스키마를 관리할 수 있습니다. 플래그가 활성화되면 새 API 및 기능을 사용할 수 있습니다.", "ACTIONS": "액션", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index 39836f5dfc..543456df24 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1627,8 +1627,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Интерфејсот за најавување ќе ги користи поставките на стандардната организација (а не од примерот) ако не е поставен контекст на организацијата", "OIDCTOKENEXCHANGE": "Размена на токени OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Овозможете го експерименталниот тип на грант urn:ietf:params:oauth:grant-type:token-exchange за крајната точка на токенот OIDC. Размената на токени може да се користи за барање токени со помал опсег или имитирање на други корисници. Погледнете ја безбедносната политика за да дозволите имитирање на пример.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции за интроспекција на активирањето на OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Овозможете предизвикувачи за проекција за време на барање за интроспекција. Ова може да дејствува како заобиколување ако има забележителни проблеми со конзистентноста во одговорот на интроспекцијата, но може да има влијание врз перформансите. Планираме да ги отстраниме предизвикувачите за барањата за интроспекција во иднина.", "USERSCHEMA": "Корисничка шема", "USERSCHEMA_DESCRIPTION": "Корисничките шеми овозможуваат управување со податоци шеми на корисникот. Ако знамето е овозможено, ќе можете да го користите новиот API и неговите функции.", "ACTIONS": "Акции", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index c49867aa3e..f8e81a1310 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Als er geen organisatiecontext is ingesteld, gebruikt de inlog-UI de instellingen van de standaardorganisatie (en niet van de instantie)", "OIDCTOKENEXCHANGE": "OIDC-tokenuitwisseling", "OIDCTOKENEXCHANGE_DESCRIPTION": "Schakel het experimentele type verlening urn:ietf:params:oauth:grant-type:token-exchange in voor het OIDC-tokenendpoint. Tokenuitwisseling kan worden gebruikt om tokens met een kleinere scope op te vragen of om zich voor te doen als andere gebruikers. Raadpleeg het beveiligingsbeleid om impersonation op een instantie toe te staan.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC-triggers voor introspectieprojecties", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Schakel projectietriggers in tijdens een introspectieverzoek. Dit kan dienen als een tijdelijke oplossing als er merkbare consistentieproblemen optreden in het introspectieantwoord, maar het kan wel prestaties beïnvloeden. We zijn van plan om triggers voor introspectieverzoeken in de toekomst te verwijderen.", "USERSCHEMA": "Gebruikerschema", "USERSCHEMA_DESCRIPTION": "Met gebruikerschema's kunt u de dataschema's van gebruikers beheren. Als de vlag is ingeschakeld, kunt u de nieuwe API en zijn functies gebruiken.", "ACTIONS": "Acties", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index abf2e1ba8a..def9e920b6 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1625,8 +1625,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Jeśli nie ustawiono kontekstu organizacji, interfejs logowania będzie używać ustawień domyślnej organizacji (a nie instancji)", "OIDCTOKENEXCHANGE": "Wymiana Tokenów OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Włącz eksperymentalny typ grantu urn:ietf:params:oauth:grant-type:token-exchange dla punktu końcowego tokena OIDC. Wymiana tokenów może być używana do żądania tokenów o mniejszym zakresie lub podszywania się za innych użytkowników. Aby zezwolić na podszywanie się na instancji, zapoznaj się z polityką bezpieczeństwa.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projekcje Introspekcji Wyzwalane przez OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Włącz wyzwalacze projekcji podczas żądania introspekcji. Może to stanowić obejście, jeśli w odpowiedzi introspekcji występują zauważalne problemy z spójnością, ale może mieć wpływ na wydajność. Planujemy w przyszłości usunąć wyzwalacze dla żądań introspekcji.", "USERSCHEMA": "Schemat Użytkownika", "USERSCHEMA_DESCRIPTION": "Schematy użytkowników umożliwiają zarządzanie schematami danych użytkowników. Jeśli flaga jest włączona, będziesz mógł korzystać z nowego interfejsu API i jego funkcji.", "ACTIONS": "Akcje", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 8b858bd44e..92363fff7b 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1627,8 +1627,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "A interface de login utilizará as configurações da organização padrão (e não da instância) se nenhum contexto de organização estiver definido", "OIDCTOKENEXCHANGE": "Troca de Token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Habilita o tipo de concessão experimental urn:ietf:params:oauth:grant-type:token-exchange para o endpoint de token OIDC. A troca de token pode ser usada para solicitar tokens com escopo menor ou personificar outros usuários. Consulte a política de segurança para permitir a personificação em uma instância.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Projeções de Introspecção com Gatilho OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Habilita gatilhos de projeção durante uma solicitação de introspecção. Isso pode funcionar como uma solução alternativa se houver problemas de consistência perceptíveis na resposta de introspecção, mas pode impactar o desempenho. Planejamos remover gatilhos para solicitações de introspecção no futuro.", "USERSCHEMA": "Esquema de Usuário", "USERSCHEMAS_DESCRIPTION": "Esquemas de Usuário permitem gerenciar esquemas de dados do usuário. Se o sinalizador estiver ativado, você poderá usar a nova API e seus recursos.", "ACTIONS": "Ações", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index d2f51a81e0..a7bf9b4f23 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1624,8 +1624,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "UI-ul de conectare va utiliza setările organizației implicite (și nu din instanță) dacă nu este setat niciun context de organizație", "OIDCTOKENEXCHANGE": "Schimb de token OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Activați tipul de grant experimental urn:ietf:params:oauth:grant-type:token-exchange pentru endpointul token OIDC. Schimbul de tokenuri poate fi utilizat pentru a solicita tokenuri cu o rază de acțiune mai mică sau pentru a impersona alți utilizatori. Consultați politica de securitate pentru a permite impersonarea pe o instanță.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Proiecții de introspecție OIDC Trigger", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Activați declanșatoarele de proiecție în timpul unei solicitări de introspecție. Acest lucru poate acționa ca o soluție dacă există probleme notabile de consistență în răspunsul de introspecție, dar poate avea un impact asupra performanței. Planificăm să eliminăm declanșatoarele pentru solicitările de introspecție în viitor.", "USERSCHEMA": "Schema de utilizator", "USERSCHEMA_DESCRIPTION": "Schemele de utilizator permit gestionarea schemelor de date ale utilizatorului. Dacă indicatorul este activat, veți putea utiliza noul API și caracteristicile sale.", "ACTIONS": "Acțiuni", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 3070b311e7..40f35bdcc8 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -1679,8 +1679,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Если контекст организации не установлен, пользовательский интерфейс входа будет использовать настройки организации по умолчанию (а не экземпляра)", "OIDCTOKENEXCHANGE": "Обмен токенами OIDC", "OIDCTOKENEXCHANGE_DESCRIPTION": "Включите экспериментальный тип гранта urn:ietf:params:oauth:grant-type:token-exchange для конечной точки токена OIDC. Обмен токенами можно использовать для запроса токенов с меньшей областью действия или для impersonation (выдачи себя за) других пользователей. Информацию о разрешении impersonation на экземпляре см. в политике безопасности.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "Проекции интроспекции с триггером OIDC", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Включите триггеры проекций во время запроса интроспекции. Это может служить обходным путем, если в ответе интроспекции наблюдаются заметные проблемы согласованности, но может повлиять на производительность. В будущем мы планируем удалить триггеры для запросов интроспекции.", "USERSCHEMA": "Схема пользователя", "USERSCHEMA_DESCRIPTION": "Схемы пользователей позволяют управлять схемами данных пользователей. Если флаг включен, вы сможете использовать новый API и его функции.", "ACTIONS": "Действия", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 8f03501054..6dfe81f99c 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1630,8 +1630,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "Inloggningsgränssnittet kommer att använda inställningarna för standardorganisationen (och inte från instansen) om ingen organisationskontext är inställd", "OIDCTOKENEXCHANGE": "OIDC Token Exchange", "OIDCTOKENEXCHANGE_DESCRIPTION": "Aktivera den experimentella urn:ietf:params:oauth:grant-type:token-exchange grant-typen för OIDC-tokenändpunkten. Tokenutbyte kan användas för att begära tokens med en mindre omfattning eller impersonera andra användare. Se säkerhetspolicyn för att tillåta impersonation på en instans.", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC Trigger introspection Projections", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "Aktivera projektionstriggers under en introspektionsbegäran. Detta kan fungera som en lösning om det finns märkbara konsistensproblem i introspektionssvaret men kan påverka prestandan. Vi planerar att ta bort triggers för introspektionsbegäranden i framtiden.", "USERSCHEMA": "Användarschema", "USERSCHEMA_DESCRIPTION": "Användarscheman tillåter att hantera datascheman för användare. Om flaggan är aktiverad kommer du att kunna använda det nya API:et och dess funktioner.", "ACTIONS": "Åtgärder", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index 0431405979..c4b40d71ea 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1626,8 +1626,6 @@ "LOGINDEFAULTORG_DESCRIPTION": "如果没有设置组织上下文,登录界面将使用默认组织的设置(而不是实例的设置)", "OIDCTOKENEXCHANGE": "OIDC 令牌交换", "OIDCTOKENEXCHANGE_DESCRIPTION": "启用 OIDC 令牌端点的实验性 urn:ietf:params:oauth:grant-type:token-exchange 授权类型。令牌交换可用于请求具有较少范围的令牌或模拟其他用户。请参阅安全策略以允许在实例上模拟。", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS": "OIDC 触发内省投影", - "OIDCTRIGGERINTROSPECTIONPROJECTIONS_DESCRIPTION": "在内省请求期间启用投影触发器。如果内省响应中存在明显的一致性问题,这可以作为一个解决方法,但可能会影响性能。我们计划在未来删除内省请求的触发器。", "USERSCHEMA": "用户架构", "USERSCHEMA_DESCRIPTION": "用户架构允许管理用户的数据架构。如果启用此标志,您将可以使用新的 API 及其功能。", "ACTIONS": "操作", diff --git a/internal/api/grpc/feature/v2/converter.go b/internal/api/grpc/feature/v2/converter.go index 1f0a3b21e7..ab8ddc7d75 100644 --- a/internal/api/grpc/feature/v2/converter.go +++ b/internal/api/grpc/feature/v2/converter.go @@ -18,32 +18,30 @@ func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) (*command return nil, err } return &command.SystemFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, - DisableUserTokenEvent: req.DisableUserTokenEvent, - EnableBackChannelLogout: req.EnableBackChannelLogout, - LoginV2: loginV2, - PermissionCheckV2: req.PermissionCheckV2, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, + EnableBackChannelLogout: req.EnableBackChannelLogout, + LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, }, nil } func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse { return &feature_pb.GetSystemFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), - DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), - EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), - LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), - PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), + EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), } } @@ -53,36 +51,34 @@ func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) (*com return nil, err } return &command.InstanceFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - DebugOIDCParentError: req.DebugOidcParentError, - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, - DisableUserTokenEvent: req.DisableUserTokenEvent, - EnableBackChannelLogout: req.EnableBackChannelLogout, - LoginV2: loginV2, - PermissionCheckV2: req.PermissionCheckV2, - ConsoleUseV2UserApi: req.ConsoleUseV2UserApi, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + DebugOIDCParentError: req.DebugOidcParentError, + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + DisableUserTokenEvent: req.DisableUserTokenEvent, + EnableBackChannelLogout: req.EnableBackChannelLogout, + LoginV2: loginV2, + PermissionCheckV2: req.PermissionCheckV2, + ConsoleUseV2UserApi: req.ConsoleUseV2UserApi, }, nil } func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse { return &feature_pb.GetInstanceFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), - DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), - EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), - LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), - PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), - ConsoleUseV2UserApi: featureSourceToFlagPb(&f.ConsoleUseV2UserApi), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + DisableUserTokenEvent: featureSourceToFlagPb(&f.DisableUserTokenEvent), + EnableBackChannelLogout: featureSourceToFlagPb(&f.EnableBackChannelLogout), + LoginV2: loginV2ToLoginV2FlagPb(f.LoginV2), + PermissionCheckV2: featureSourceToFlagPb(&f.PermissionCheckV2), + ConsoleUseV2UserApi: featureSourceToFlagPb(&f.ConsoleUseV2UserApi), } } diff --git a/internal/api/grpc/feature/v2/converter_test.go b/internal/api/grpc/feature/v2/converter_test.go index d09f1839ba..7b11fc0d17 100644 --- a/internal/api/grpc/feature/v2/converter_test.go +++ b/internal/api/grpc/feature/v2/converter_test.go @@ -19,24 +19,22 @@ import ( func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), LoginV2: &feature_pb.LoginV2{ Required: true, BaseUri: gu.Ptr("https://login.com"), }, } want := &command.SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), LoginV2: &feature.LoginV2{ Required: true, BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, @@ -58,10 +56,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -104,10 +98,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -148,14 +138,13 @@ func Test_systemFeaturesToPb(t *testing.T) { func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - DebugOidcParentError: gu.Ptr(true), - OidcSingleV1SessionTermination: gu.Ptr(true), - EnableBackChannelLogout: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + DebugOidcParentError: gu.Ptr(true), + OidcSingleV1SessionTermination: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), LoginV2: &feature_pb.LoginV2{ Required: true, BaseUri: gu.Ptr("https://login.com"), @@ -163,14 +152,13 @@ func Test_instanceFeaturesToCommand(t *testing.T) { ConsoleUseV2UserApi: gu.Ptr(true), } want := &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - DebugOIDCParentError: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), - EnableBackChannelLogout: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + DebugOIDCParentError: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), + EnableBackChannelLogout: gu.Ptr(true), LoginV2: &feature.LoginV2{ Required: true, BaseURI: &url.URL{Scheme: "https", Host: "login.com"}, @@ -193,10 +181,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -243,10 +227,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, diff --git a/internal/api/grpc/feature/v2/integration_test/feature_test.go b/internal/api/grpc/feature/v2/integration_test/feature_test.go index fe09242429..369f5b37b8 100644 --- a/internal/api/grpc/feature/v2/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2/integration_test/feature_test.go @@ -58,7 +58,7 @@ func TestServer_SetSystemFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetSystemFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -76,7 +76,7 @@ func TestServer_SetSystemFeatures(t *testing.T) { args: args{ ctx: SystemCTX, req: &feature.SetSystemFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetSystemFeaturesResponse{ @@ -170,8 +170,8 @@ func TestServer_GetSystemFeatures(t *testing.T) { name: "some features", prepare: func(t *testing.T) { _, err := Client.SetSystemFeatures(SystemCTX, &feature.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(false), }) require.NoError(t, err) }, @@ -184,7 +184,7 @@ func TestServer_GetSystemFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ + UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_SYSTEM, }, @@ -208,7 +208,6 @@ func TestServer_GetSystemFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } @@ -230,7 +229,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: OrgCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -248,7 +247,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetInstanceFeaturesResponse{ @@ -360,10 +359,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -374,9 +369,8 @@ func TestServer_GetInstanceFeatures(t *testing.T) { name: "some features, no inheritance", prepare: func(t *testing.T) { _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) require.NoError(t, err) }, @@ -389,10 +383,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_INSTANCE, - }, UserSchema: &feature.FeatureFlag{ Enabled: true, Source: feature.Source_SOURCE_INSTANCE, @@ -418,10 +408,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -446,7 +432,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/grpc/feature/v2beta/converter.go b/internal/api/grpc/feature/v2beta/converter.go index 8927b16e29..dc791d4c51 100644 --- a/internal/api/grpc/feature/v2beta/converter.go +++ b/internal/api/grpc/feature/v2beta/converter.go @@ -10,49 +10,45 @@ import ( func systemFeaturesToCommand(req *feature_pb.SetSystemFeaturesRequest) *command.SystemFeatures { return &command.SystemFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } } func systemFeaturesToPb(f *query.SystemFeatures) *feature_pb.GetSystemFeaturesResponse { return &feature_pb.GetSystemFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } } func instanceFeaturesToCommand(req *feature_pb.SetInstanceFeaturesRequest) *command.InstanceFeatures { return &command.InstanceFeatures{ - LoginDefaultOrg: req.LoginDefaultOrg, - TriggerIntrospectionProjections: req.OidcTriggerIntrospectionProjections, - UserSchema: req.UserSchema, - TokenExchange: req.OidcTokenExchange, - ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), - DebugOIDCParentError: req.DebugOidcParentError, - OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, + LoginDefaultOrg: req.LoginDefaultOrg, + UserSchema: req.UserSchema, + TokenExchange: req.OidcTokenExchange, + ImprovedPerformance: improvedPerformanceListToDomain(req.ImprovedPerformance), + DebugOIDCParentError: req.DebugOidcParentError, + OIDCSingleV1SessionTermination: req.OidcSingleV1SessionTermination, } } func instanceFeaturesToPb(f *query.InstanceFeatures) *feature_pb.GetInstanceFeaturesResponse { return &feature_pb.GetInstanceFeaturesResponse{ - Details: object.DomainToDetailsPb(f.Details), - LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), - OidcTriggerIntrospectionProjections: featureSourceToFlagPb(&f.TriggerIntrospectionProjections), - UserSchema: featureSourceToFlagPb(&f.UserSchema), - OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), - ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), - DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), - OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), + Details: object.DomainToDetailsPb(f.Details), + LoginDefaultOrg: featureSourceToFlagPb(&f.LoginDefaultOrg), + UserSchema: featureSourceToFlagPb(&f.UserSchema), + OidcTokenExchange: featureSourceToFlagPb(&f.TokenExchange), + ImprovedPerformance: featureSourceToImprovedPerformanceFlagPb(&f.ImprovedPerformance), + DebugOidcParentError: featureSourceToFlagPb(&f.DebugOIDCParentError), + OidcSingleV1SessionTermination: featureSourceToFlagPb(&f.OIDCSingleV1SessionTermination), } } diff --git a/internal/api/grpc/feature/v2beta/converter_test.go b/internal/api/grpc/feature/v2beta/converter_test.go index 5fdb5e993e..ec681011f0 100644 --- a/internal/api/grpc/feature/v2beta/converter_test.go +++ b/internal/api/grpc/feature/v2beta/converter_test.go @@ -18,20 +18,18 @@ import ( func Test_systemFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetSystemFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), } got := systemFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -48,10 +46,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelSystem, Value: true, @@ -79,10 +73,6 @@ func Test_systemFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, @@ -106,20 +96,18 @@ func Test_systemFeaturesToPb(t *testing.T) { func Test_instanceFeaturesToCommand(t *testing.T) { arg := &feature_pb.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OidcTokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OidcSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OidcTokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OidcSingleV1SessionTermination: gu.Ptr(true), } want := &command.InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - TokenExchange: gu.Ptr(true), - ImprovedPerformance: nil, - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + TokenExchange: gu.Ptr(true), + ImprovedPerformance: nil, + OIDCSingleV1SessionTermination: gu.Ptr(true), } got := instanceFeaturesToCommand(arg) assert.Equal(t, want, got) @@ -136,10 +124,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: query.FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, UserSchema: query.FeatureSource[bool]{ Level: feature.LevelInstance, Value: true, @@ -167,10 +151,6 @@ func Test_instanceFeaturesToPb(t *testing.T) { Enabled: true, Source: feature_pb.Source_SOURCE_SYSTEM, }, - OidcTriggerIntrospectionProjections: &feature_pb.FeatureFlag{ - Enabled: false, - Source: feature_pb.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature_pb.FeatureFlag{ Enabled: true, Source: feature_pb.Source_SOURCE_INSTANCE, diff --git a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go index 549bc4ef0a..4e24bb2a4f 100644 --- a/internal/api/grpc/feature/v2beta/integration_test/feature_test.go +++ b/internal/api/grpc/feature/v2beta/integration_test/feature_test.go @@ -61,7 +61,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: OrgCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, wantErr: true, @@ -79,7 +79,7 @@ func TestServer_SetInstanceFeatures(t *testing.T) { args: args{ ctx: IamCTX, req: &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), }, }, want: &feature.SetInstanceFeaturesResponse{ @@ -190,10 +190,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -204,9 +200,8 @@ func TestServer_GetInstanceFeatures(t *testing.T) { name: "some features, no inheritance", prepare: func(t *testing.T) { _, err := Client.SetInstanceFeatures(IamCTX, &feature.SetInstanceFeaturesRequest{ - LoginDefaultOrg: gu.Ptr(true), - OidcTriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), }) require.NoError(t, err) }, @@ -219,10 +214,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_INSTANCE, - }, UserSchema: &feature.FeatureFlag{ Enabled: true, Source: feature.Source_SOURCE_INSTANCE, @@ -248,10 +239,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { Enabled: true, Source: feature.Source_SOURCE_INSTANCE, }, - OidcTriggerIntrospectionProjections: &feature.FeatureFlag{ - Enabled: false, - Source: feature.Source_SOURCE_UNSPECIFIED, - }, UserSchema: &feature.FeatureFlag{ Enabled: false, Source: feature.Source_SOURCE_UNSPECIFIED, @@ -276,7 +263,6 @@ func TestServer_GetInstanceFeatures(t *testing.T) { } require.NoError(t, err) assertFeatureFlag(t, tt.want.LoginDefaultOrg, got.LoginDefaultOrg) - assertFeatureFlag(t, tt.want.OidcTriggerIntrospectionProjections, got.OidcTriggerIntrospectionProjections) assertFeatureFlag(t, tt.want.UserSchema, got.UserSchema) }) } diff --git a/internal/api/oidc/access_token.go b/internal/api/oidc/access_token.go index 5c0b9c9f66..08337bb5af 100644 --- a/internal/api/oidc/access_token.go +++ b/internal/api/oidc/access_token.go @@ -11,7 +11,6 @@ import ( "github.com/zitadel/oidc/v3/pkg/op" "golang.org/x/text/language" - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" @@ -122,7 +121,7 @@ func (s *Server) assertClientScopesForPAT(ctx context.Context, token *accessToke if err != nil { return zerrors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") } - roles, err := s.query.SearchProjectRoles(ctx, authz.GetFeatures(ctx).TriggerIntrospectionProjections, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) + roles, err := s.query.SearchProjectRoles(ctx, false, &query.ProjectRoleSearchQueries{Queries: []query.SearchQuery{projectIDQuery}}, nil) if err != nil { return err } diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index b3bc836343..2a31dd964b 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -18,48 +18,11 @@ import ( oidc_api "github.com/zitadel/zitadel/internal/api/oidc" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/pkg/grpc/feature/v2" "github.com/zitadel/zitadel/pkg/grpc/management" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) -// TestServer_UserInfo is a top-level test which re-executes the actual -// userinfo integration test against a matrix of different feature flags. -// This ensure that the response of the different implementations remains the same. func TestServer_UserInfo(t *testing.T) { - iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) - t.Cleanup(func() { - _, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{}) - require.NoError(t, err) - }) - tests := []struct { - name string - trigger bool - }{ - { - name: "trigger enabled", - trigger: true, - }, - { - name: "trigger disabled", - trigger: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{ - OidcTriggerIntrospectionProjections: &tt.trigger, - }) - require.NoError(t, err) - testServer_UserInfo(t) - }) - } -} - -// testServer_UserInfo is the actual userinfo integration test, -// which calls the userinfo endpoint with different client configurations, roles and token scopes. -func testServer_UserInfo(t *testing.T) { const ( roleFoo = "foo" roleBar = "bar" diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index ee022eb3e9..e5479a4683 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -23,12 +23,6 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR err = oidcError(err) span.EndWithError(err) }() - - features := authz.GetFeatures(ctx) - if features.TriggerIntrospectionProjections { - query.TriggerIntrospectionProjections(ctx) - } - ctx, cancel := context.WithCancel(ctx) defer cancel() diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 5266500e7a..170ff49c94 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -33,12 +33,6 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques err = oidcError(err) span.EndWithError(err) }() - - features := authz.GetFeatures(ctx) - if features.TriggerIntrospectionProjections { - query.TriggerOIDCUserInfoProjections(ctx) - } - token, err := s.verifyAccessToken(ctx, r.Data.AccessToken) if err != nil { return nil, op.NewStatusError(oidc.ErrAccessDenied().WithDescription("access token invalid").WithParent(err).WithReturnParentToClient(authz.GetFeatures(ctx).DebugOIDCParentError), http.StatusUnauthorized) diff --git a/internal/command/instance_features.go b/internal/command/instance_features.go index 21de5653a9..04f2621705 100644 --- a/internal/command/instance_features.go +++ b/internal/command/instance_features.go @@ -13,23 +13,21 @@ import ( ) type InstanceFeatures struct { - LoginDefaultOrg *bool - TriggerIntrospectionProjections *bool - UserSchema *bool - TokenExchange *bool - ImprovedPerformance []feature.ImprovedPerformanceType - DebugOIDCParentError *bool - OIDCSingleV1SessionTermination *bool - DisableUserTokenEvent *bool - EnableBackChannelLogout *bool - LoginV2 *feature.LoginV2 - PermissionCheckV2 *bool - ConsoleUseV2UserApi *bool + LoginDefaultOrg *bool + UserSchema *bool + TokenExchange *bool + ImprovedPerformance []feature.ImprovedPerformanceType + DebugOIDCParentError *bool + OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool + EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool + ConsoleUseV2UserApi *bool } func (m *InstanceFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && - m.TriggerIntrospectionProjections == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/instance_features_model.go b/internal/command/instance_features_model.go index 8ca2865eae..8fe9dd0284 100644 --- a/internal/command/instance_features_model.go +++ b/internal/command/instance_features_model.go @@ -67,7 +67,6 @@ func (m *InstanceFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { feature_v1.DefaultLoginInstanceEventType, feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -93,9 +92,6 @@ func reduceInstanceFeature(features *InstanceFeatures, key feature.Key, value an case feature.KeyLoginDefaultOrg: v := value.(bool) features.LoginDefaultOrg = &v - case feature.KeyTriggerIntrospectionProjections: - v := value.(bool) - features.TriggerIntrospectionProjections = &v case feature.KeyTokenExchange: v := value.(bool) features.TokenExchange = &v @@ -132,7 +128,6 @@ func (wm *InstanceFeaturesWriteModel) setCommands(ctx context.Context, f *Instan aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.InstanceLoginDefaultOrgEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.InstanceTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.InstanceTokenExchangeEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.InstanceUserSchemaEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.InstanceImprovedPerformanceEventType) diff --git a/internal/command/instance_features_test.go b/internal/command/instance_features_test.go index 8d0c7d5964..f0bea9752d 100644 --- a/internal/command/instance_features_test.go +++ b/internal/command/instance_features_test.go @@ -95,24 +95,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ResourceOwner: "instance1", }, }, - { - name: "set TriggerIntrospectionProjections", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - ), - ), - ), - args: args{ctx, &InstanceFeatures{ - TriggerIntrospectionProjections: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "instance1", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -156,10 +138,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - ), feature_v2.NewSetEvent[bool]( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, true, @@ -171,10 +149,9 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ), ), args: args{ctx, &InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "instance1", @@ -189,10 +166,6 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( ctx, aggregate, feature_v2.InstanceResetEventType, @@ -211,16 +184,11 @@ func TestCommands_SetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, false, - ), ), ), args: args{ctx, &InstanceFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - OIDCSingleV1SessionTermination: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ ResourceOwner: "instance1", diff --git a/internal/command/system_features.go b/internal/command/system_features.go index f20c9f3cda..c2d4f9f9e7 100644 --- a/internal/command/system_features.go +++ b/internal/command/system_features.go @@ -10,21 +10,19 @@ import ( ) type SystemFeatures struct { - LoginDefaultOrg *bool - TriggerIntrospectionProjections *bool - TokenExchange *bool - UserSchema *bool - ImprovedPerformance []feature.ImprovedPerformanceType - OIDCSingleV1SessionTermination *bool - DisableUserTokenEvent *bool - EnableBackChannelLogout *bool - LoginV2 *feature.LoginV2 - PermissionCheckV2 *bool + LoginDefaultOrg *bool + TokenExchange *bool + UserSchema *bool + ImprovedPerformance []feature.ImprovedPerformanceType + OIDCSingleV1SessionTermination *bool + DisableUserTokenEvent *bool + EnableBackChannelLogout *bool + LoginV2 *feature.LoginV2 + PermissionCheckV2 *bool } func (m *SystemFeatures) isEmpty() bool { return m.LoginDefaultOrg == nil && - m.TriggerIntrospectionProjections == nil && m.UserSchema == nil && m.TokenExchange == nil && // nil check to allow unset improvements diff --git a/internal/command/system_features_model.go b/internal/command/system_features_model.go index f1e6ba6357..212d00e6ce 100644 --- a/internal/command/system_features_model.go +++ b/internal/command/system_features_model.go @@ -60,7 +60,6 @@ func (m *SystemFeaturesWriteModel) Query() *eventstore.SearchQueryBuilder { EventTypes( feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -84,9 +83,6 @@ func reduceSystemFeature(features *SystemFeatures, key feature.Key, value any) { case feature.KeyLoginDefaultOrg: v := value.(bool) features.LoginDefaultOrg = &v - case feature.KeyTriggerIntrospectionProjections: - v := value.(bool) - features.TriggerIntrospectionProjections = &v case feature.KeyUserSchema: v := value.(bool) features.UserSchema = &v @@ -116,7 +112,6 @@ func (wm *SystemFeaturesWriteModel) setCommands(ctx context.Context, f *SystemFe aggregate := feature_v2.NewAggregate(wm.AggregateID, wm.ResourceOwner) cmds := make([]eventstore.Command, 0, len(feature.KeyValues())-1) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.LoginDefaultOrg, f.LoginDefaultOrg, feature_v2.SystemLoginDefaultOrgEventType) - cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TriggerIntrospectionProjections, f.TriggerIntrospectionProjections, feature_v2.SystemTriggerIntrospectionProjectionsEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.UserSchema, f.UserSchema, feature_v2.SystemUserSchemaEventType) cmds = appendFeatureUpdate(ctx, cmds, aggregate, wm.TokenExchange, f.TokenExchange, feature_v2.SystemTokenExchangeEventType) cmds = appendFeatureSliceUpdate(ctx, cmds, aggregate, wm.ImprovedPerformance, f.ImprovedPerformance, feature_v2.SystemImprovedPerformanceEventType) diff --git a/internal/command/system_features_test.go b/internal/command/system_features_test.go index ff6aef8104..2defd23d5e 100644 --- a/internal/command/system_features_test.go +++ b/internal/command/system_features_test.go @@ -63,24 +63,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ResourceOwner: "SYSTEM", }, }, - { - name: "set TriggerIntrospectionProjections", - eventstore: expectEventstore( - expectFilter(), - expectPush( - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - ), - ), - ), - args: args{context.Background(), &SystemFeatures{ - TriggerIntrospectionProjections: gu.Ptr(true), - }}, - want: &domain.ObjectDetails{ - ResourceOwner: "SYSTEM", - }, - }, { name: "set UserSchema", eventstore: expectEventstore( @@ -124,10 +106,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -139,10 +117,9 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ), ), args: args{context.Background(), &SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(true), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(true), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", @@ -157,10 +134,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, )), - eventFromEventPusher(feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - )), eventFromEventPusher(feature_v2.NewResetEvent( context.Background(), aggregate, feature_v2.SystemResetEventType, @@ -175,10 +148,6 @@ func TestCommands_SetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, true, ), - feature_v2.NewSetEvent[bool]( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, false, - ), feature_v2.NewSetEvent[bool]( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, true, @@ -190,10 +159,9 @@ func TestCommands_SetSystemFeatures(t *testing.T) { ), ), args: args{context.Background(), &SystemFeatures{ - LoginDefaultOrg: gu.Ptr(true), - TriggerIntrospectionProjections: gu.Ptr(false), - UserSchema: gu.Ptr(true), - OIDCSingleV1SessionTermination: gu.Ptr(false), + LoginDefaultOrg: gu.Ptr(true), + UserSchema: gu.Ptr(true), + OIDCSingleV1SessionTermination: gu.Ptr(false), }}, want: &domain.ObjectDetails{ ResourceOwner: "SYSTEM", diff --git a/internal/feature/feature.go b/internal/feature/feature.go index 107b06edf1..5e28338904 100644 --- a/internal/feature/feature.go +++ b/internal/feature/feature.go @@ -9,21 +9,20 @@ import ( type Key int const ( - // Reserved: 3, 6, 8 + // Reserved: 2, 3, 6, 8 - KeyUnspecified Key = 0 - KeyLoginDefaultOrg Key = 1 - KeyTriggerIntrospectionProjections Key = 2 - KeyUserSchema Key = 4 - KeyTokenExchange Key = 5 - KeyImprovedPerformance Key = 7 - KeyDebugOIDCParentError Key = 9 - KeyOIDCSingleV1SessionTermination Key = 10 - KeyDisableUserTokenEvent Key = 11 - KeyEnableBackChannelLogout Key = 12 - KeyLoginV2 Key = 13 - KeyPermissionCheckV2 Key = 14 - KeyConsoleUseV2UserApi Key = 15 + KeyUnspecified Key = 0 + KeyLoginDefaultOrg Key = 1 + KeyUserSchema Key = 4 + KeyTokenExchange Key = 5 + KeyImprovedPerformance Key = 7 + KeyDebugOIDCParentError Key = 9 + KeyOIDCSingleV1SessionTermination Key = 10 + KeyDisableUserTokenEvent Key = 11 + KeyEnableBackChannelLogout Key = 12 + KeyLoginV2 Key = 13 + KeyPermissionCheckV2 Key = 14 + KeyConsoleUseV2UserApi Key = 15 ) //go:generate enumer -type Level -transform snake -trimprefix Level @@ -40,18 +39,17 @@ const ( ) type Features struct { - LoginDefaultOrg bool `json:"login_default_org,omitempty"` - TriggerIntrospectionProjections bool `json:"trigger_introspection_projections,omitempty"` - UserSchema bool `json:"user_schema,omitempty"` - TokenExchange bool `json:"token_exchange,omitempty"` - ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` - DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` - OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` - DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` - EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` - LoginV2 LoginV2 `json:"login_v2,omitempty"` - PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` - ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` + LoginDefaultOrg bool `json:"login_default_org,omitempty"` + UserSchema bool `json:"user_schema,omitempty"` + TokenExchange bool `json:"token_exchange,omitempty"` + ImprovedPerformance []ImprovedPerformanceType `json:"improved_performance,omitempty"` + DebugOIDCParentError bool `json:"debug_oidc_parent_error,omitempty"` + OIDCSingleV1SessionTermination bool `json:"oidc_single_v1_session_termination,omitempty"` + DisableUserTokenEvent bool `json:"disable_user_token_event,omitempty"` + EnableBackChannelLogout bool `json:"enable_back_channel_logout,omitempty"` + LoginV2 LoginV2 `json:"login_v2,omitempty"` + PermissionCheckV2 bool `json:"permission_check_v2,omitempty"` + ConsoleUseV2UserApi bool `json:"console_use_v2_user_api,omitempty"` } /* Note: do not generate the stringer or enumer for this type, is it breaks existing events */ diff --git a/internal/feature/feature_test.go b/internal/feature/feature_test.go index abb8968d6f..d9a459e3db 100644 --- a/internal/feature/feature_test.go +++ b/internal/feature/feature_test.go @@ -11,7 +11,6 @@ func TestKey(t *testing.T) { tests := []string{ "unspecified", "login_default_org", - "trigger_introspection_projections", } for _, want := range tests { t.Run(want, func(t *testing.T) { diff --git a/internal/feature/key_enumer.go b/internal/feature/key_enumer.go index 1b4fb9a3ad..9d6f5877e0 100644 --- a/internal/feature/key_enumer.go +++ b/internal/feature/key_enumer.go @@ -8,8 +8,8 @@ import ( ) const ( - _KeyName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" - _KeyLowerName_0 = "unspecifiedlogin_default_orgtrigger_introspection_projections" + _KeyName_0 = "unspecifiedlogin_default_org" + _KeyLowerName_0 = "unspecifiedlogin_default_org" _KeyName_1 = "user_schematoken_exchange" _KeyLowerName_1 = "user_schematoken_exchange" _KeyName_2 = "improved_performance" @@ -19,7 +19,7 @@ const ( ) var ( - _KeyIndex_0 = [...]uint8{0, 11, 28, 61} + _KeyIndex_0 = [...]uint8{0, 11, 28} _KeyIndex_1 = [...]uint8{0, 11, 25} _KeyIndex_2 = [...]uint8{0, 20} _KeyIndex_3 = [...]uint8{0, 23, 57, 81, 107, 115, 134, 157} @@ -27,7 +27,7 @@ var ( func (i Key) String() string { switch { - case 0 <= i && i <= 2: + case 0 <= i && i <= 1: return _KeyName_0[_KeyIndex_0[i]:_KeyIndex_0[i+1]] case 4 <= i && i <= 5: i -= 4 @@ -48,7 +48,6 @@ func _KeyNoOp() { var x [1]struct{} _ = x[KeyUnspecified-(0)] _ = x[KeyLoginDefaultOrg-(1)] - _ = x[KeyTriggerIntrospectionProjections-(2)] _ = x[KeyUserSchema-(4)] _ = x[KeyTokenExchange-(5)] _ = x[KeyImprovedPerformance-(7)] @@ -61,15 +60,13 @@ func _KeyNoOp() { _ = x[KeyConsoleUseV2UserApi-(15)] } -var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyTriggerIntrospectionProjections, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} +var _KeyValues = []Key{KeyUnspecified, KeyLoginDefaultOrg, KeyUserSchema, KeyTokenExchange, KeyImprovedPerformance, KeyDebugOIDCParentError, KeyOIDCSingleV1SessionTermination, KeyDisableUserTokenEvent, KeyEnableBackChannelLogout, KeyLoginV2, KeyPermissionCheckV2, KeyConsoleUseV2UserApi} var _KeyNameToValueMap = map[string]Key{ _KeyName_0[0:11]: KeyUnspecified, _KeyLowerName_0[0:11]: KeyUnspecified, _KeyName_0[11:28]: KeyLoginDefaultOrg, _KeyLowerName_0[11:28]: KeyLoginDefaultOrg, - _KeyName_0[28:61]: KeyTriggerIntrospectionProjections, - _KeyLowerName_0[28:61]: KeyTriggerIntrospectionProjections, _KeyName_1[0:11]: KeyUserSchema, _KeyLowerName_1[0:11]: KeyUserSchema, _KeyName_1[11:25]: KeyTokenExchange, @@ -95,7 +92,6 @@ var _KeyNameToValueMap = map[string]Key{ var _KeyNames = []string{ _KeyName_0[0:11], _KeyName_0[11:28], - _KeyName_0[28:61], _KeyName_1[0:11], _KeyName_1[11:25], _KeyName_2[0:20], diff --git a/internal/query/instance_features.go b/internal/query/instance_features.go index 9e0081a542..73f51bfdf7 100644 --- a/internal/query/instance_features.go +++ b/internal/query/instance_features.go @@ -8,19 +8,18 @@ import ( ) type InstanceFeatures struct { - Details *domain.ObjectDetails - LoginDefaultOrg FeatureSource[bool] - TriggerIntrospectionProjections FeatureSource[bool] - UserSchema FeatureSource[bool] - TokenExchange FeatureSource[bool] - ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - DebugOIDCParentError FeatureSource[bool] - OIDCSingleV1SessionTermination FeatureSource[bool] - DisableUserTokenEvent FeatureSource[bool] - EnableBackChannelLogout FeatureSource[bool] - LoginV2 FeatureSource[*feature.LoginV2] - PermissionCheckV2 FeatureSource[bool] - ConsoleUseV2UserApi FeatureSource[bool] + Details *domain.ObjectDetails + LoginDefaultOrg FeatureSource[bool] + UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] + ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] + DebugOIDCParentError FeatureSource[bool] + OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] + EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] + ConsoleUseV2UserApi FeatureSource[bool] } func (q *Queries) GetInstanceFeatures(ctx context.Context, cascade bool) (_ *InstanceFeatures, err error) { diff --git a/internal/query/instance_features_model.go b/internal/query/instance_features_model.go index a30009e9ee..fa0f638bed 100644 --- a/internal/query/instance_features_model.go +++ b/internal/query/instance_features_model.go @@ -63,7 +63,6 @@ func (m *InstanceFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { feature_v1.DefaultLoginInstanceEventType, feature_v2.InstanceResetEventType, feature_v2.InstanceLoginDefaultOrgEventType, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, feature_v2.InstanceUserSchemaEventType, feature_v2.InstanceTokenExchangeEventType, feature_v2.InstanceImprovedPerformanceEventType, @@ -91,7 +90,6 @@ func (m *InstanceFeaturesReadModel) populateFromSystem() bool { return false } m.instance.LoginDefaultOrg = m.system.LoginDefaultOrg - m.instance.TriggerIntrospectionProjections = m.system.TriggerIntrospectionProjections m.instance.UserSchema = m.system.UserSchema m.instance.TokenExchange = m.system.TokenExchange m.instance.ImprovedPerformance = m.system.ImprovedPerformance @@ -112,8 +110,6 @@ func reduceInstanceFeatureSet[T any](features *InstanceFeatures, event *feature_ return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) - case feature.KeyTriggerIntrospectionProjections: - features.TriggerIntrospectionProjections.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/instance_features_test.go b/internal/query/instance_features_test.go index af662e4898..5c5f8ecc64 100644 --- a/internal/query/instance_features_test.go +++ b/internal/query/instance_features_test.go @@ -71,10 +71,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelUnspecified, - Value: false, - }, }, }, { @@ -89,10 +85,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -108,10 +100,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelInstance, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelInstance, Value: false, @@ -130,10 +118,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -142,10 +126,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), ), ), args: args{true}, @@ -157,10 +137,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelSystem, Value: true, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -175,10 +151,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( ctx, aggregate, feature_v2.InstanceUserSchemaEventType, false, @@ -187,10 +159,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { ctx, aggregate, feature_v2.InstanceResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - ctx, aggregate, - feature_v2.InstanceTriggerIntrospectionProjectionsEventType, true, - )), ), ), args: args{false}, @@ -202,10 +170,6 @@ func TestQueries_GetInstanceFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelInstance, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/introspection.go b/internal/query/introspection.go index ee96bf576b..a3ef125466 100644 --- a/internal/query/introspection.go +++ b/internal/query/introspection.go @@ -25,12 +25,6 @@ var introspectionTriggerHandlers = sync.OnceValue(func() []*handler.Handler { ) }) -// TriggerIntrospectionProjections triggers all projections -// relevant to introspection queries concurrently. -func TriggerIntrospectionProjections(ctx context.Context) { - triggerBatch(ctx, introspectionTriggerHandlers()...) -} - type AppType string const ( diff --git a/internal/query/projection/instance_features.go b/internal/query/projection/instance_features.go index 3c33ff6fdf..32ec2cf111 100644 --- a/internal/query/projection/instance_features.go +++ b/internal/query/projection/instance_features.go @@ -64,10 +64,6 @@ func (*instanceFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.InstanceLoginDefaultOrgEventType, Reduce: reduceInstanceSetFeature[bool], }, - { - Event: feature_v2.InstanceTriggerIntrospectionProjectionsEventType, - Reduce: reduceInstanceSetFeature[bool], - }, { Event: feature_v2.InstanceUserSchemaEventType, Reduce: reduceInstanceSetFeature[bool], diff --git a/internal/query/projection/system_features.go b/internal/query/projection/system_features.go index 3f70f7dfa6..32f49108e6 100644 --- a/internal/query/projection/system_features.go +++ b/internal/query/projection/system_features.go @@ -56,10 +56,6 @@ func (*systemFeatureProjection) Reducers() []handler.AggregateReducer { Event: feature_v2.SystemLoginDefaultOrgEventType, Reduce: reduceSystemSetFeature[bool], }, - { - Event: feature_v2.SystemTriggerIntrospectionProjectionsEventType, - Reduce: reduceSystemSetFeature[bool], - }, { Event: feature_v2.SystemUserSchemaEventType, Reduce: reduceSystemSetFeature[bool], diff --git a/internal/query/system_features.go b/internal/query/system_features.go index 8c340ce739..34410cb9b8 100644 --- a/internal/query/system_features.go +++ b/internal/query/system_features.go @@ -20,16 +20,15 @@ func (f *FeatureSource[T]) set(level feature.Level, value any) { type SystemFeatures struct { Details *domain.ObjectDetails - LoginDefaultOrg FeatureSource[bool] - TriggerIntrospectionProjections FeatureSource[bool] - UserSchema FeatureSource[bool] - TokenExchange FeatureSource[bool] - ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] - OIDCSingleV1SessionTermination FeatureSource[bool] - DisableUserTokenEvent FeatureSource[bool] - EnableBackChannelLogout FeatureSource[bool] - LoginV2 FeatureSource[*feature.LoginV2] - PermissionCheckV2 FeatureSource[bool] + LoginDefaultOrg FeatureSource[bool] + UserSchema FeatureSource[bool] + TokenExchange FeatureSource[bool] + ImprovedPerformance FeatureSource[[]feature.ImprovedPerformanceType] + OIDCSingleV1SessionTermination FeatureSource[bool] + DisableUserTokenEvent FeatureSource[bool] + EnableBackChannelLogout FeatureSource[bool] + LoginV2 FeatureSource[*feature.LoginV2] + PermissionCheckV2 FeatureSource[bool] } func (q *Queries) GetSystemFeatures(ctx context.Context) (_ *SystemFeatures, err error) { diff --git a/internal/query/system_features_model.go b/internal/query/system_features_model.go index f91bc7d1e9..67045f314d 100644 --- a/internal/query/system_features_model.go +++ b/internal/query/system_features_model.go @@ -56,7 +56,6 @@ func (m *SystemFeaturesReadModel) Query() *eventstore.SearchQueryBuilder { EventTypes( feature_v2.SystemResetEventType, feature_v2.SystemLoginDefaultOrgEventType, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, feature_v2.SystemUserSchemaEventType, feature_v2.SystemTokenExchangeEventType, feature_v2.SystemImprovedPerformanceEventType, @@ -84,8 +83,6 @@ func reduceSystemFeatureSet[T any](features *SystemFeatures, event *feature_v2.S return nil case feature.KeyLoginDefaultOrg: features.LoginDefaultOrg.set(level, event.Value) - case feature.KeyTriggerIntrospectionProjections: - features.TriggerIntrospectionProjections.set(level, event.Value) case feature.KeyUserSchema: features.UserSchema.set(level, event.Value) case feature.KeyTokenExchange: diff --git a/internal/query/system_features_test.go b/internal/query/system_features_test.go index da59ceb549..7aa12a6a8f 100644 --- a/internal/query/system_features_test.go +++ b/internal/query/system_features_test.go @@ -49,10 +49,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -67,10 +63,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelSystem, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelSystem, Value: false, @@ -85,10 +77,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -97,10 +85,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), ), ), want: &SystemFeatures{ @@ -111,10 +95,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, @@ -129,10 +109,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemLoginDefaultOrgEventType, false, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), eventFromEventPusher(feature_v2.NewSetEvent( context.Background(), aggregate, feature_v2.SystemUserSchemaEventType, false, @@ -141,10 +117,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { context.Background(), aggregate, feature_v2.SystemResetEventType, )), - eventFromEventPusher(feature_v2.NewSetEvent( - context.Background(), aggregate, - feature_v2.SystemTriggerIntrospectionProjectionsEventType, true, - )), ), ), want: &SystemFeatures{ @@ -155,10 +127,6 @@ func TestQueries_GetSystemFeatures(t *testing.T) { Level: feature.LevelUnspecified, Value: false, }, - TriggerIntrospectionProjections: FeatureSource[bool]{ - Level: feature.LevelSystem, - Value: true, - }, UserSchema: FeatureSource[bool]{ Level: feature.LevelUnspecified, Value: false, diff --git a/internal/query/userinfo.go b/internal/query/userinfo.go index 0e749f09b3..aa2920dfba 100644 --- a/internal/query/userinfo.go +++ b/internal/query/userinfo.go @@ -31,12 +31,6 @@ var oidcUserInfoTriggerHandlers = sync.OnceValue(func() []*handler.Handler { } }) -// TriggerOIDCUserInfoProjections triggers all projections -// relevant to userinfo queries concurrently. -func TriggerOIDCUserInfoProjections(ctx context.Context) { - triggerBatch(ctx, oidcUserInfoTriggerHandlers()...) -} - var ( //go:embed userinfo_by_id.sql oidcUserInfoQueryTmpl string diff --git a/internal/repository/feature/feature_v2/eventstore.go b/internal/repository/feature/feature_v2/eventstore.go index 25d0f270f6..293c1ee3cd 100644 --- a/internal/repository/feature/feature_v2/eventstore.go +++ b/internal/repository/feature/feature_v2/eventstore.go @@ -8,7 +8,6 @@ import ( func init() { eventstore.RegisterFilterEventMapper(AggregateType, SystemResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, SystemLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, SystemTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, SystemImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) @@ -20,7 +19,6 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, InstanceResetEventType, eventstore.GenericEventMapper[ResetEvent]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceLoginDefaultOrgEventType, eventstore.GenericEventMapper[SetEvent[bool]]) - eventstore.RegisterFilterEventMapper(AggregateType, InstanceTriggerIntrospectionProjectionsEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceUserSchemaEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceTokenExchangeEventType, eventstore.GenericEventMapper[SetEvent[bool]]) eventstore.RegisterFilterEventMapper(AggregateType, InstanceImprovedPerformanceEventType, eventstore.GenericEventMapper[SetEvent[[]feature.ImprovedPerformanceType]]) diff --git a/internal/repository/feature/feature_v2/feature.go b/internal/repository/feature/feature_v2/feature.go index a87042d72a..2859b65ebf 100644 --- a/internal/repository/feature/feature_v2/feature.go +++ b/internal/repository/feature/feature_v2/feature.go @@ -11,31 +11,29 @@ import ( ) var ( - SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) - SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) - SystemTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTriggerIntrospectionProjections) - SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) - SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) - SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) - SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) - SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) - SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) - SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) - SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) + SystemResetEventType = resetEventTypeFromFeature(feature.LevelSystem) + SystemLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginDefaultOrg) + SystemUserSchemaEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyUserSchema) + SystemTokenExchangeEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyTokenExchange) + SystemImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyImprovedPerformance) + SystemOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelSystem, feature.KeyOIDCSingleV1SessionTermination) + SystemDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelSystem, feature.KeyDisableUserTokenEvent) + SystemEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelSystem, feature.KeyEnableBackChannelLogout) + SystemLoginVersion = setEventTypeFromFeature(feature.LevelSystem, feature.KeyLoginV2) + SystemPermissionCheckV2 = setEventTypeFromFeature(feature.LevelSystem, feature.KeyPermissionCheckV2) - InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) - InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) - InstanceTriggerIntrospectionProjectionsEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTriggerIntrospectionProjections) - InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) - InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) - InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) - InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) - InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) - InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) - InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) - InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) - InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) - InstanceConsoleUseV2UserApi = setEventTypeFromFeature(feature.LevelInstance, feature.KeyConsoleUseV2UserApi) + InstanceResetEventType = resetEventTypeFromFeature(feature.LevelInstance) + InstanceLoginDefaultOrgEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginDefaultOrg) + InstanceUserSchemaEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyUserSchema) + InstanceTokenExchangeEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyTokenExchange) + InstanceImprovedPerformanceEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyImprovedPerformance) + InstanceDebugOIDCParentErrorEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDebugOIDCParentError) + InstanceOIDCSingleV1SessionTerminationEventType = setEventTypeFromFeature(feature.LevelInstance, feature.KeyOIDCSingleV1SessionTermination) + InstanceDisableUserTokenEvent = setEventTypeFromFeature(feature.LevelInstance, feature.KeyDisableUserTokenEvent) + InstanceEnableBackChannelLogout = setEventTypeFromFeature(feature.LevelInstance, feature.KeyEnableBackChannelLogout) + InstanceLoginVersion = setEventTypeFromFeature(feature.LevelInstance, feature.KeyLoginV2) + InstancePermissionCheckV2 = setEventTypeFromFeature(feature.LevelInstance, feature.KeyPermissionCheckV2) + InstanceConsoleUseV2UserApi = setEventTypeFromFeature(feature.LevelInstance, feature.KeyConsoleUseV2UserApi) ) const ( diff --git a/proto/zitadel/feature/v2/instance.proto b/proto/zitadel/feature/v2/instance.proto index efbe5e3cdf..f3467f723d 100644 --- a/proto/zitadel/feature/v2/instance.proto +++ b/proto/zitadel/feature/v2/instance.proto @@ -11,20 +11,14 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6, 8; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 2, 3, 6, 8; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -118,8 +112,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7, 9; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 3, 4, 7, 9; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -128,13 +122,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2/system.proto b/proto/zitadel/feature/v2/system.proto index ac39e62f09..d3fbe6bccb 100644 --- a/proto/zitadel/feature/v2/system.proto +++ b/proto/zitadel/feature/v2/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2;feature"; message SetSystemFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 2, 3, 6; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -20,13 +20,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -98,8 +91,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 4, 7; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; zitadel.object.v2.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -108,13 +101,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/instance.proto b/proto/zitadel/feature/v2beta/instance.proto index 7968668e50..ac7a6c9286 100644 --- a/proto/zitadel/feature/v2beta/instance.proto +++ b/proto/zitadel/feature/v2beta/instance.proto @@ -11,20 +11,14 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetInstanceFeaturesRequest{ - reserved 3, 6, 8; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 2, 3, 6, 8; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; description: "The login UI will use the settings of the default org (and not from the instance) if no organization context is set"; } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -84,8 +78,8 @@ message GetInstanceFeaturesRequest { } message GetInstanceFeaturesResponse { - reserved 4, 7, 9; - reserved "oidc_legacy_introspection", "actions", "web_key"; + reserved 3, 4, 7, 9; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions", "web_key"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -94,13 +88,6 @@ message GetInstanceFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; diff --git a/proto/zitadel/feature/v2beta/system.proto b/proto/zitadel/feature/v2beta/system.proto index 95bf71da9b..ae500eb87b 100644 --- a/proto/zitadel/feature/v2beta/system.proto +++ b/proto/zitadel/feature/v2beta/system.proto @@ -11,8 +11,8 @@ import "zitadel/feature/v2beta/feature.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta;feature"; message SetSystemFeaturesRequest{ - reserved 3, 6; - reserved "oidc_legacy_introspection", "actions"; + reserved 2, 3, 6; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; optional bool login_default_org = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -20,13 +20,6 @@ message SetSystemFeaturesRequest{ } ]; - optional bool oidc_trigger_introspection_projections = 2 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - optional bool user_schema = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; @@ -71,8 +64,8 @@ message ResetSystemFeaturesResponse { message GetSystemFeaturesRequest {} message GetSystemFeaturesResponse { - reserved 4, 7; - reserved "oidc_legacy_introspection", "actions"; + reserved 3, 4, 7; + reserved "oidc_trigger_introspection_projections", "oidc_legacy_introspection", "actions"; zitadel.object.v2beta.Details details = 1; FeatureFlag login_default_org = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { @@ -81,13 +74,6 @@ message GetSystemFeaturesResponse { } ]; - FeatureFlag oidc_trigger_introspection_projections = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - example: "true"; - description: "Enable projection triggers during an introspection request. This can act as workaround if there are noticeable consistency issues in the introspection response but can have an impact on performance. We are planning to remove triggers for introspection requests in the future. Please raise an issue if you needed to enable this feature."; - } - ]; - FeatureFlag user_schema = 5 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "true"; From b7d447e313891d344fb55fc056c1a1a4654f57f4 Mon Sep 17 00:00:00 2001 From: Federico Coppede Date: Mon, 30 Jun 2025 09:21:08 -0300 Subject: [PATCH 110/123] docs(legal): Update account-lockout-policy.md (#10124) Review finished for the account lockout policy. Main changes: - Revised wording - Removed free account from the policy scope - Fixed broken link to the support form in the customer portal --------- Co-authored-by: Maximilian --- .../legal/policies/account-lockout-policy.md | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/docs/docs/legal/policies/account-lockout-policy.md b/docs/docs/legal/policies/account-lockout-policy.md index a593eac1bc..663fd12d9d 100644 --- a/docs/docs/legal/policies/account-lockout-policy.md +++ b/docs/docs/legal/policies/account-lockout-policy.md @@ -4,56 +4,69 @@ sidebar_label: Account Lockout Policy custom_edit_url: null --- -Last updated on May 31, 2023 +Last updated on June 25, 2025 -This policy is an annex to the [Terms of Service](../terms-of-service) that clarifies your obligations and our procedure handling requests where you can't get access to your ZITADEL Cloud services and data. This policy is applicable to situations where we, ZITADEL, need to restore your access for a otherwise available service and not in cases where the services are unavailable. +This policy is an annex to the [Terms of Service](../terms-of-service) and outlines your responsibilities, as well as our procedures, for handling situations where you are unable to access your ZITADEL Cloud services or data. -## Why to do we have this policy? +It applies specifically to cases where **ZITADEL** must restore your access to services that are otherwise operational, and does **not** cover service outages or unavailability. -Users may not be able to access our services anymore due to loss of credentials or misconfiguration. -In certain circumstances it might not be possible to recover the credentials through a self-service flow (eg, loss of 2FA credentials) or access the system to undo the configuration that caused the issue. -These cases might require help from our support, so you can regain access to your data. -We will require some initial information and conditions to be able to assist you, and will require further information to handle the request. -We also keep the right to refuse any such request without providing a reason, in case you can't provide the requested information. +## Why do we have this policy? -## Scope +Users may lose access to ZITADEL services due to lost credentials or misconfiguration. -In scope of this policy are requests to recover +In some cases, it may not be possible to recover access through self-service options—for example, losing access to 2FA credentials or being unable to reverse a misconfiguration. These situations may require support from our team to help you regain access to your data. -- ZITADEL Cloud account (customer portal) -- Manager accounts to a specific instance -- Undo configuration changes resulting in lockout (eg, misconfigured Action) +To assist with such requests, we will require specific information and may request additional details throughout the process. -Out of scope are requests to recover access +**ZITADEL reserves the right to decline any access recovery request without providing a reason if the required information cannot be verified or provided.** + + +## Scope of This Policy + +This policy applies to the following situations: + +- Loss of access to your **ZITADEL Cloud Admin Account** (customer portal) +- Inability to access **Instance Manager accounts** for a specific instance +- Need to **undo configuration changes** that caused a lockout (e.g., a misconfigured Action) + + +## Out of Scope + +The following types of access recovery requests are **not** covered by this policy: + +- Situations where you can request access from another **Admin** or **Instance Manager** +- Requests made by **end-users** who should instead contact their Admin or Manager +- Issues related to **self-hosted ZITADEL instances** +- **Free accounts/Instances** -- Where you have to option to ask another Admin/Manager -- by end-users who should ask an Admin/Manager instead -- self-hosted instances ## Process -Before you send a request to restore access to your account, please make sure that can't ask your manager/admin or another manager/admin to recover access. +Before submitting a request to restore access to your account, please ensure that you are unable to regain access through your existing **Manager** or **Admin**, or by contacting another **Manager/Admin** within your organization. -### ZITADEL Cloud account -If you need to recover your ZITADEL Cloud account for the customer portal, please send an email to [support@zitadel.com](mailto:support@zitadel.com?subject=ZITADEL%20Cloud%20account%20lockout): +### ZITADEL Cloud account (Customer Portal) + +Please visit the [support page in the customer portal](https://zitadel.com/admin/support): - State clearly in the subject line that this is related to an account lockout for a ZITADEL Cloud account - The sender's email address must match the verified email address of the account owner - State the reason why you're not able to recover the account yourself -Please allow us time to validate your request. -Our support will get back to you to request additional information for verification. +Please allow us time to validate your request. +Our support team will follow up with additional verification steps if needed. -### Manager access to an Instance +### Instance Manager access recovery If you need to recover a Manager account to an instance, please make sure you can't recover the account via another user or service user with Manager permissions. -Please visit the [support page in the customer portal](https://zitadel.cloud/admin/support): +Please visit the [support page in the customer portal](https://zitadel.com/admin/support): -- State clearly in the subject line that this is related to an account lockout the affected instance +- State clearly in the subject line that this is related to an account lockout **for** the affected instance +- The sender's email address must match the verified email address of the affected instance manager - State the reason why you're not able to recover the account yourself -Please allow us time to validate your request. -Our support will get back to you to request additional information for verification. +Please allow us time to validate your request. +Our support team will follow up with additional verification steps if needed. + From 64a03fba28dc73e97f851c4d04cdb7e6596fc575 Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Mon, 30 Jun 2025 11:07:33 -0400 Subject: [PATCH 111/123] fix(api): return typed saml form post data in idp intent (#10136) # Which Problems Are Solved The current user V2 API returns a `[]byte` containing a whole HTML document including the form on `StartIdentifyProviderIntent` for intents based on form post (e.g. SAML POST bindings). This is not usable for most clients as they cannot handle that and render a whole page inside their app. For redirect based intents, the url to which the client needs to redirect is returned. # How the Problems Are Solved - Changed the returned type to a new `FormData` message containing the url and a `fields` map. - internal changes: - Session.GetAuth now returns an `Auth` interfacce and error instead of (content string, redirect bool) - Auth interface has two implementations: `RedirectAuth` and `FormAuth` - All use of the GetAuth function now type switch on the returned auth object - A template has been added to the login UI to execute the form post automatically (as is). # Additional Changes - Some intent integration test did not check the redirect url and were wrongly configured. # Additional Context - relates to zitadel/typescript#410 --- .../user/v2/integration_test/user_test.go | 22 ++- internal/api/grpc/user/v2/intent.go | 27 ++-- .../user/v2beta/integration_test/user_test.go | 22 ++- internal/api/grpc/user/v2beta/user.go | 27 ++-- .../api/ui/login/external_provider_handler.go | 32 ++++- internal/command/idp_intent_test.go | 135 +++++++++++++++--- internal/idp/providers/apple/apple_test.go | 8 +- .../idp/providers/azuread/azuread_test.go | 8 +- internal/idp/providers/azuread/session.go | 2 +- internal/idp/providers/github/github_test.go | 8 +- internal/idp/providers/gitlab/gitlab_test.go | 8 +- internal/idp/providers/google/google_test.go | 8 +- internal/idp/providers/jwt/jwt_test.go | 8 +- internal/idp/providers/jwt/session.go | 2 +- internal/idp/providers/ldap/session.go | 2 +- internal/idp/providers/oauth/oauth2_test.go | 8 +- internal/idp/providers/oauth/session.go | 2 +- internal/idp/providers/oidc/oidc_test.go | 8 +- internal/idp/providers/oidc/session.go | 2 +- internal/idp/providers/saml/saml_test.go | 130 +++++++++++++++++ internal/idp/providers/saml/session.go | 93 +++++++----- internal/idp/session.go | 30 +++- proto/zitadel/user/v2/idp.proto | 18 +++ proto/zitadel/user/v2/user_service.proto | 4 + proto/zitadel/user/v2beta/idp.proto | 18 +++ proto/zitadel/user/v2beta/user_service.proto | 13 +- 26 files changed, 512 insertions(+), 133 deletions(-) diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 4eee44ab44..1776c57fcb 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -2057,7 +2057,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2081,7 +2081,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2105,7 +2105,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - postForm: true, + url: "http://localhost:8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + postForm: true, }, wantErr: false, }, @@ -2143,9 +2145,11 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } require.NoError(t, err) - if tt.want.url != "" { + if tt.want.url != "" && !tt.want.postForm { authUrl, err := url.Parse(got.GetAuthUrl()) require.NoError(t, err) + + assert.Equal(t, tt.want.url, authUrl.Scheme+"://"+authUrl.Host+authUrl.Path) require.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) for _, existing := range tt.want.parametersExisting { @@ -2156,7 +2160,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } if tt.want.postForm { - assert.NotEmpty(t, got.GetPostForm()) + assert.Equal(t, tt.want.url, got.GetFormData().GetUrl()) + + require.Len(t, got.GetFormData().GetFields(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) + for _, existing := range tt.want.parametersExisting { + assert.Contains(t, got.GetFormData().GetFields(), existing) + } + for key, equal := range tt.want.parametersEqual { + assert.Equal(t, got.GetFormData().GetFields()[key], equal) + } } integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{ Details: tt.want.details, diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index 5514b6ef03..fd65d61dfb 100644 --- a/internal/api/grpc/user/v2/intent.go +++ b/internal/api/grpc/user/v2/intent.go @@ -52,19 +52,28 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re if err != nil { return nil, err } - content, redirect := session.GetAuth(ctx) - if redirect { + auth, err := session.GetAuth(ctx) + if err != nil { + return nil, err + } + switch a := auth.(type) { + case *idp.RedirectAuth: return &user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, + NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, + }, nil + case *idp.FormAuth: + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_FormData{ + FormData: &user.FormData{ + Url: a.URL, + Fields: a.Fields, + }, + }, }, nil } - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil + return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index 250322d66f..7b02f7da70 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -2058,7 +2058,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2082,7 +2082,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - url: "http://" + Instance.Domain + ":8000/sso", + url: "http://localhost:8000/sso", parametersExisting: []string{"RelayState", "SAMLRequest"}, }, wantErr: false, @@ -2106,7 +2106,9 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { ChangeDate: timestamppb.Now(), ResourceOwner: Instance.ID(), }, - postForm: true, + url: "http://localhost:8000/sso", + parametersExisting: []string{"RelayState", "SAMLRequest"}, + postForm: true, }, wantErr: false, }, @@ -2120,9 +2122,11 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } require.NoError(t, err) - if tt.want.url != "" { + if tt.want.url != "" && !tt.want.postForm { authUrl, err := url.Parse(got.GetAuthUrl()) require.NoError(t, err) + + assert.Equal(t, tt.want.url, authUrl.Scheme+"://"+authUrl.Host+authUrl.Path) require.Len(t, authUrl.Query(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) for _, existing := range tt.want.parametersExisting { @@ -2133,7 +2137,15 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { } } if tt.want.postForm { - assert.NotEmpty(t, got.GetPostForm()) + assert.Equal(t, tt.want.url, got.GetFormData().GetUrl()) + + require.Len(t, got.GetFormData().GetFields(), len(tt.want.parametersEqual)+len(tt.want.parametersExisting)) + for _, existing := range tt.want.parametersExisting { + assert.Contains(t, got.GetFormData().GetFields(), existing) + } + for key, equal := range tt.want.parametersEqual { + assert.Equal(t, got.GetFormData().GetFields()[key], equal) + } } integration.AssertDetails(t, &user.StartIdentityProviderIntentResponse{ Details: tt.want.details, diff --git a/internal/api/grpc/user/v2beta/user.go b/internal/api/grpc/user/v2beta/user.go index 93afbde0aa..49f0c7d9c7 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -380,19 +380,28 @@ func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.Re if err != nil { return nil, err } - content, redirect := session.GetAuth(ctx) - if redirect { + auth, err := session.GetAuth(ctx) + if err != nil { + return nil, err + } + switch a := auth.(type) { + case *idp.RedirectAuth: return &user.StartIdentityProviderIntentResponse{ Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: content}, + NextStep: &user.StartIdentityProviderIntentResponse_AuthUrl{AuthUrl: a.RedirectURL}, + }, nil + case *idp.FormAuth: + return &user.StartIdentityProviderIntentResponse{ + Details: object.DomainToDetailsPb(details), + NextStep: &user.StartIdentityProviderIntentResponse_FormData{ + FormData: &user.FormData{ + Url: a.URL, + Fields: a.Fields, + }, + }, }, nil } - return &user.StartIdentityProviderIntentResponse{ - Details: object.DomainToDetailsPb(details), - NextStep: &user.StartIdentityProviderIntentResponse_PostForm{ - PostForm: []byte(content), - }, - }, nil + return nil, zerrors.ThrowInvalidArgumentf(nil, "USERv2-3g2j3", "type oneOf %T in method StartIdentityProviderIntent not implemented", auth) } func (s *Server) startLDAPIntent(ctx context.Context, idpID string, ldapCredentials *user.LDAPCredentials) (*user.StartIdentityProviderIntentResponse, error) { diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index 6202c38c8b..abd20088ba 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -48,6 +48,18 @@ const ( tmplExternalNotFoundOption = "externalnotfoundoption" ) +var ( + samlFormPost = template.Must(template.New("saml-post-form").Parse(` + +{{range $key, $value := .Fields}} + +{{end}} + + + +`)) +) + type externalIDPData struct { IDPConfigID string `schema:"idpConfigID"` } @@ -201,15 +213,21 @@ func (l *Login) handleIDP(w http.ResponseWriter, r *http.Request, authReq *domai l.externalAuthFailed(w, r, authReq, err) return } - - content, redirect := session.GetAuth(r.Context()) - if redirect { - http.Redirect(w, r, content, http.StatusFound) + auth, err := session.GetAuth(r.Context()) + if err != nil { + l.renderInternalError(w, r, authReq, err) return } - _, err = w.Write([]byte(content)) - if err != nil { - l.renderError(w, r, authReq, err) + switch a := auth.(type) { + case *idp.RedirectAuth: + http.Redirect(w, r, a.RedirectURL, http.StatusFound) + return + case *idp.FormAuth: + err = samlFormPost.Execute(w, a) + if err != nil { + l.renderError(w, r, authReq, err) + return + } return } } diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 6cf835f521..e0f4e2ffdb 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -432,9 +432,8 @@ func TestCommands_AuthFromProvider(t *testing.T) { samlRootURL string } type res struct { - content string - redirect bool - err error + auth idp.Auth + err error } tests := []struct { name string @@ -579,8 +578,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { callbackURL: "url", }, res{ - content: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id", - redirect: true, + auth: &idp.RedirectAuth{RedirectURL: "auth?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&state=id"}, }, }, { @@ -671,8 +669,7 @@ func TestCommands_AuthFromProvider(t *testing.T) { callbackURL: "url", }, res{ - content: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=id", - redirect: true, + auth: &idp.RedirectAuth{RedirectURL: "https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?client_id=clientID&prompt=select_account&redirect_uri=url&response_type=code&scope=openid+profile+User.Read&state=id"}, }, }, } @@ -686,13 +683,12 @@ func TestCommands_AuthFromProvider(t *testing.T) { _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) - var content string - var redirect bool + var got idp.Auth if err == nil { - content, redirect = session.GetAuth(tt.args.ctx) + got, err = session.GetAuth(tt.args.ctx) + assert.Equal(t, tt.res.auth, got) + assert.NoError(t, err) } - assert.Equal(t, tt.res.redirect, redirect) - assert.Equal(t, tt.res.content, content) }) } } @@ -811,6 +807,97 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { }, }, }, + { + "saml post auth", + fields{ + secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + eventstore: expectEventstore( + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("key"), + }, + []byte("certificate"), + "", + false, + gu.Ptr(domain.SAMLNameIDFormatUnspecified), + "", + false, + rep_idp.Options{}, + )), + ), + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + instance.NewSAMLIDPAddedEvent(context.Background(), &instance.NewAggregate("instance").Aggregate, + "idp", + "name", + []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + }, []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + "", + false, + gu.Ptr(domain.SAMLNameIDFormatUnspecified), + "", + false, + rep_idp.Options{}, + )), + ), + expectFilter( + eventFromEventPusherWithInstanceID( + "instance", + func() eventstore.Command { + success, _ := url.Parse("https://success.url") + failure, _ := url.Parse("https://failure.url") + return idpintent.NewStartedEvent( + context.Background(), + &idpintent.NewAggregate("id", "instance").Aggregate, + success, + failure, + "idp", + nil, + ) + }(), + ), + ), + expectRandomPush( + []eventstore.Command{ + idpintent.NewSAMLRequestEvent( + context.Background(), + &idpintent.NewAggregate("id", "instance").Aggregate, + "request", + ), + }, + ), + ), + idGenerator: mock.ExpectID(t, "id"), + }, + args{ + ctx: authz.SetCtxData(context.Background(), authz.CtxData{OrgID: "ro"}), + idpID: "idp", + callbackURL: "url", + samlRootURL: "samlurl", + }, + res{ + url: "http://localhost:8000/sso", + values: map[string]string{ + "SAMLRequest": "", // generated IDs so not assertable + "RelayState": "id", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -822,16 +909,30 @@ func TestCommands_AuthFromProvider_SAML(t *testing.T) { _, session, err := c.AuthFromProvider(tt.args.ctx, tt.args.idpID, tt.args.callbackURL, tt.args.samlRootURL) require.ErrorIs(t, err, tt.res.err) - content, _ := session.GetAuth(tt.args.ctx) - authURL, err := url.Parse(content) + auth, err := session.GetAuth(tt.args.ctx) require.NoError(t, err) + var authURL *url.URL + authFields := make(map[string]string) + + switch a := auth.(type) { + case *idp.RedirectAuth: + authURL, err = url.Parse(a.RedirectURL) + for key, values := range authURL.Query() { + authFields[key] = values[0] + } + require.NoError(t, err) + case *idp.FormAuth: + authURL, err = url.Parse(a.URL) + require.NoError(t, err) + authFields = a.Fields + } + assert.Equal(t, tt.res.url, authURL.Scheme+"://"+authURL.Host+authURL.Path) - query := authURL.Query() for k, v := range tt.res.values { - assert.True(t, query.Has(k)) + assert.Contains(t, authFields, k) if v != "" { - assert.Equal(t, v, query.Get(k)) + assert.Equal(t, v, authFields[k]) } } }) diff --git a/internal/idp/providers/apple/apple_test.go b/internal/idp/providers/apple/apple_test.go index f3b7e81a1a..7d1f3a8481 100644 --- a/internal/idp/providers/apple/apple_test.go +++ b/internal/idp/providers/apple/apple_test.go @@ -62,10 +62,10 @@ func TestProvider_BeginAuth(t *testing.T) { ctx := context.Background() session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - content, redirect := session.GetAuth(ctx) - contentExpected, redirectExpected := tt.want.GetAuth(ctx) - a.Equal(redirectExpected, redirect) - a.Equal(contentExpected, content) + auth, err := session.GetAuth(ctx) + authExpected, errExpected := tt.want.GetAuth(ctx) + a.ErrorIs(err, errExpected) + a.Equal(authExpected, auth) }) } } diff --git a/internal/idp/providers/azuread/azuread_test.go b/internal/idp/providers/azuread/azuread_test.go index 122a70bb07..e46815cc8e 100644 --- a/internal/idp/providers/azuread/azuread_test.go +++ b/internal/idp/providers/azuread/azuread_test.go @@ -81,10 +81,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/azuread/session.go b/internal/idp/providers/azuread/session.go index 169784fb58..f417897893 100644 --- a/internal/idp/providers/azuread/session.go +++ b/internal/idp/providers/azuread/session.go @@ -28,7 +28,7 @@ func NewSession(provider *Provider, code string) *Session { } // GetAuth implements the [idp.Provider] interface by calling the wrapped [oauth.Session]. -func (s *Session) GetAuth(ctx context.Context) (content string, redirect bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return s.oauth().GetAuth(ctx) } diff --git a/internal/idp/providers/github/github_test.go b/internal/idp/providers/github/github_test.go index 6274b51841..42f03c050d 100644 --- a/internal/idp/providers/github/github_test.go +++ b/internal/idp/providers/github/github_test.go @@ -48,10 +48,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/gitlab/gitlab_test.go b/internal/idp/providers/gitlab/gitlab_test.go index 24b813bc81..99b28c5003 100644 --- a/internal/idp/providers/gitlab/gitlab_test.go +++ b/internal/idp/providers/gitlab/gitlab_test.go @@ -59,10 +59,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/google/google_test.go b/internal/idp/providers/google/google_test.go index b95f8eaf9f..b8f31b86e3 100644 --- a/internal/idp/providers/google/google_test.go +++ b/internal/idp/providers/google/google_test.go @@ -48,10 +48,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/jwt/jwt_test.go b/internal/idp/providers/jwt/jwt_test.go index 5756c58e07..aba337d2ee 100644 --- a/internal/idp/providers/jwt/jwt_test.go +++ b/internal/idp/providers/jwt/jwt_test.go @@ -119,10 +119,10 @@ func TestProvider_BeginAuth(t *testing.T) { } if tt.want.err == nil { a.NoError(err) - wantHeaders, wantContent := tt.want.session.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.session.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) } }) } diff --git a/internal/idp/providers/jwt/session.go b/internal/idp/providers/jwt/session.go index 85b164a9c5..0d91986fc9 100644 --- a/internal/idp/providers/jwt/session.go +++ b/internal/idp/providers/jwt/session.go @@ -42,7 +42,7 @@ func NewSessionFromRequest(provider *Provider, r *http.Request) *Session { } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/ldap/session.go b/internal/idp/providers/ldap/session.go index a78dd02d73..6a56cd6132 100644 --- a/internal/idp/providers/ldap/session.go +++ b/internal/idp/providers/ldap/session.go @@ -39,7 +39,7 @@ func NewSession(provider *Provider, username, password string) *Session { } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.loginUrl) } diff --git a/internal/idp/providers/oauth/oauth2_test.go b/internal/idp/providers/oauth/oauth2_test.go index 984315ac1f..93a0dd404f 100644 --- a/internal/idp/providers/oauth/oauth2_test.go +++ b/internal/idp/providers/oauth/oauth2_test.go @@ -80,10 +80,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/oauth/session.go b/internal/idp/providers/oauth/session.go index c9e175d1cf..27d38b1740 100644 --- a/internal/idp/providers/oauth/session.go +++ b/internal/idp/providers/oauth/session.go @@ -37,7 +37,7 @@ func NewSession(provider *Provider, code string, idpArguments map[string]any) *S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/oidc/oidc_test.go b/internal/idp/providers/oidc/oidc_test.go index a46f09f13f..86e23f95d2 100644 --- a/internal/idp/providers/oidc/oidc_test.go +++ b/internal/idp/providers/oidc/oidc_test.go @@ -98,10 +98,10 @@ func TestProvider_BeginAuth(t *testing.T) { session, err := provider.BeginAuth(ctx, "testState") r.NoError(err) - wantHeaders, wantContent := tt.want.GetAuth(ctx) - gotHeaders, gotContent := session.GetAuth(ctx) - a.Equal(wantHeaders, gotHeaders) - a.Equal(wantContent, gotContent) + wantAuth, wantErr := tt.want.GetAuth(ctx) + gotAuth, gotErr := session.GetAuth(ctx) + a.Equal(wantAuth, gotAuth) + a.ErrorIs(gotErr, wantErr) }) } } diff --git a/internal/idp/providers/oidc/session.go b/internal/idp/providers/oidc/session.go index 9e1e55baf5..08e277a9cc 100644 --- a/internal/idp/providers/oidc/session.go +++ b/internal/idp/providers/oidc/session.go @@ -33,7 +33,7 @@ func NewSession(provider *Provider, code string, idpArguments map[string]any) *S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { return idp.Redirect(s.AuthURL) } diff --git a/internal/idp/providers/saml/saml_test.go b/internal/idp/providers/saml/saml_test.go index 69ff231ccc..5e76e6dcaa 100644 --- a/internal/idp/providers/saml/saml_test.go +++ b/internal/idp/providers/saml/saml_test.go @@ -1,7 +1,9 @@ package saml import ( + "context" "encoding/xml" + "net/url" "testing" "time" @@ -11,10 +13,138 @@ import ( "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/idp" "github.com/zitadel/zitadel/internal/idp/providers/saml/requesttracker" "github.com/zitadel/zitadel/internal/zerrors" ) +func TestProvider_BeginAuth(t *testing.T) { + requestTracker := requesttracker.New( + func(ctx context.Context, authRequestID, samlRequestID string) error { + assert.Equal(t, "state", authRequestID) + return nil + }, + func(ctx context.Context, authRequestID string) (*samlsp.TrackedRequest, error) { + return &samlsp.TrackedRequest{ + SAMLRequestID: "state", + Index: authRequestID, + }, nil + }, + ) + type fields struct { + name string + rootURL string + metadata []byte + certificate []byte + key []byte + options []ProviderOpts + } + type args struct { + state string + } + type want struct { + err func(error) bool + authType idp.Auth + ssoURL string + relayState string + } + tests := []struct { + name string + fields fields + args args + want want + }{ + { + name: "redirect binding, success", + fields: fields{ + name: "saml", + rootURL: "https://localhost:8080", + metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + options: []ProviderOpts{ + WithCustomRequestTracker(requestTracker), + }, + }, + args: args{ + state: "state", + }, + want: want{ + authType: &idp.RedirectAuth{}, + ssoURL: "http://localhost:8000/sso", + relayState: "state", + }, + }, + { + name: "post binding, success", + fields: fields{ + name: "saml", + rootURL: "https://localhost:8080", + metadata: []byte("\n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8Ahs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+aucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWxm+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURNB2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0OBBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uvNONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEfy/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsbGFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTLUzreO96WzlBBMtY=\n \n \n \n \n \n \n \n urn:oasis:names:tc:SAML:2.0:nameid-format:transient\n \n \n \n"), + certificate: []byte("-----BEGIN CERTIFICATE-----\nMIIC2zCCAcOgAwIBAgIIAy/jm1gAAdEwDQYJKoZIhvcNAQELBQAwEjEQMA4GA1UE\nChMHWklUQURFTDAeFw0yMzA4MzAwNzExMTVaFw0yNDA4MjkwNzExMTVaMBIxEDAO\nBgNVBAoTB1pJVEFERUwwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDE\nd3TztGgSb3LBVZn8f60NbFCyZW+F9HPiMCr9F9T45Zc0fgmMwxId0WzRD5Y/3yc1\ndHJzt+Bsxvw12aUHbIPiothqk3lINoFzl2H/cSfIW3nehKyNOUqdBQ8B4mvaqH81\njTjoJ/JTJAwzglHk6JAWjhOyx9aep1yBqYa3QASeTaW9sxkpB0Co1L2UPNhuMwZq\n8RA9NkTfmYVcVBeNqihler5MhruFtqrv+J0ftwc1stw8uCN89ADyr4Ni+e+FeWar\nQs9Bkfc6KLF/5IXa9HCsHNPaaoYPY6I6RSaG4/DKoSKIEe1/GSVG1FTpZ8trUZxv\nU+xXS6gEalXcrJsiX8aXAgMBAAGjNTAzMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE\nDDAKBggrBgEFBQcDATAMBgNVHRMBAf8EAjAAMA0GCSqGSIb3DQEBCwUAA4IBAQCx\n/dRNIj0N/16zJhZR/ahkc2AkvDXYxyr4JRT5wK9GQDNl/oaX3debRuSi/tfaXFIX\naJA6PxM4J49ZaiEpLrKfxMz5kAhjKchCBEMcH3mGt+iNZH7EOyTvHjpGrP2OZrsh\nO17yrvN3HuQxIU6roJlqtZz2iAADsoPtwOO4D7hupm9XTMkSnAmlMWOo/q46Jz89\n1sMxB+dXmH/zV0wgwh0omZfLV0u89mvdq269VhcjNBpBYSnN1ccqYWd5iwziob3I\nvaavGHGfkbvRUn/tKftYuTK30q03R+e9YbmlWZ0v695owh2e/apCzowQsCKfSVC8\nOxVyt5XkHq1tWwVyBmFp\n-----END CERTIFICATE-----\n"), + key: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"), + options: []ProviderOpts{ + WithCustomRequestTracker(requestTracker), + }, + }, + args: args{ + state: "state", + }, + want: want{ + authType: &idp.FormAuth{}, + ssoURL: "http://localhost:8000/sso", + relayState: "state", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := assert.New(t) + + provider, err := New( + tt.fields.name, + tt.fields.rootURL, + tt.fields.metadata, + tt.fields.certificate, + tt.fields.key, + tt.fields.options..., + ) + require.NoError(t, err) + + ctx := context.Background() + session, err := provider.BeginAuth(ctx, tt.args.state, nil) + if tt.want.err != nil && !tt.want.err(err) { + a.Fail("invalid error", err) + } + if tt.want.err == nil { + a.NoError(err) + gotAuth, gotErr := session.GetAuth(ctx) + a.NoError(gotErr) + a.IsType(tt.want.authType, gotAuth) + + var ssoURL, relayState, samlRequest string + switch auth := gotAuth.(type) { + case *idp.RedirectAuth: + gotRedirect, err := url.Parse(auth.RedirectURL) + a.NoError(err) + gotQuery := gotRedirect.Query() + + ssoURL = gotRedirect.Scheme + "://" + gotRedirect.Host + gotRedirect.Path + relayState = gotQuery.Get("RelayState") + samlRequest = gotQuery.Get("SAMLRequest") + case *idp.FormAuth: + ssoURL = auth.URL + relayState = auth.Fields["RelayState"] + samlRequest = auth.Fields["SAMLRequest"] + } + a.Equal(tt.want.ssoURL, ssoURL) + a.Equal(tt.want.relayState, relayState) + a.NotEmpty(samlRequest) + } + }) + } +} + func TestProvider_Options(t *testing.T) { type fields struct { name string diff --git a/internal/idp/providers/saml/session.go b/internal/idp/providers/saml/session.go index e2a1655a26..e1f32209b0 100644 --- a/internal/idp/providers/saml/session.go +++ b/internal/idp/providers/saml/session.go @@ -1,13 +1,14 @@ package saml import ( - "bytes" "context" + "encoding/base64" "errors" "net/http" "net/url" "time" + "github.com/beevik/etree" "github.com/crewjam/saml" "github.com/crewjam/saml/samlsp" @@ -43,22 +44,15 @@ func NewSession(provider *Provider, requestID string, request *http.Request) (*S } // GetAuth implements the [idp.Session] interface. -func (s *Session) GetAuth(ctx context.Context) (string, bool) { - url, _ := url.Parse(s.state) - resp := NewTempResponseWriter() - +func (s *Session) GetAuth(ctx context.Context) (idp.Auth, error) { + url, err := url.Parse(s.state) + if err != nil { + return nil, err + } request := &http.Request{ URL: url, } - s.ServiceProvider.HandleStartAuthFlow( - resp, - request.WithContext(ctx), - ) - - if location := resp.Header().Get("Location"); location != "" { - return idp.Redirect(location) - } - return idp.Form(resp.content.String()) + return s.auth(request.WithContext(ctx)) } // PersistentParameters implements the [idp.Session] interface. @@ -130,24 +124,57 @@ func (s *Session) transientMappingID() (string, error) { return "", zerrors.ThrowInvalidArgument(nil, "SAML-swwg2", "Errors.Intent.MissingSingleMappingAttribute") } -type TempResponseWriter struct { - header http.Header - content *bytes.Buffer -} - -func (w *TempResponseWriter) Header() http.Header { - return w.header -} - -func (w *TempResponseWriter) Write(content []byte) (int, error) { - return w.content.Write(content) -} - -func (w *TempResponseWriter) WriteHeader(statusCode int) {} - -func NewTempResponseWriter() *TempResponseWriter { - return &TempResponseWriter{ - header: map[string][]string{}, - content: bytes.NewBuffer([]byte{}), +// auth is a modified copy of the [samlsp.Middleware.HandleStartAuthFlow] method. +// Instead of writing the response to the http.ResponseWriter, it returns the auth request as an [idp.Auth]. +// In case of an error, it returns the error directly and does not write to the response. +func (s *Session) auth(r *http.Request) (idp.Auth, error) { + if r.URL.Path == s.ServiceProvider.ServiceProvider.AcsURL.Path { + // should never occur, but was handled in the original method, so we keep it here + return nil, zerrors.ThrowInvalidArgument(nil, "SAML-Eoi24", "don't wrap Middleware with RequireAccount") } + + var binding, bindingLocation string + if s.ServiceProvider.Binding != "" { + binding = s.ServiceProvider.Binding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + } else { + binding = saml.HTTPRedirectBinding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + if bindingLocation == "" { + binding = saml.HTTPPostBinding + bindingLocation = s.ServiceProvider.ServiceProvider.GetSSOBindingLocation(binding) + } + } + + authReq, err := s.ServiceProvider.ServiceProvider.MakeAuthenticationRequest(bindingLocation, binding, s.ServiceProvider.ResponseBinding) + if err != nil { + return nil, err + } + relayState, err := s.ServiceProvider.RequestTracker.TrackRequest(nil, r, authReq.ID) + if err != nil { + return nil, err + } + + if binding == saml.HTTPRedirectBinding { + redirectURL, err := authReq.Redirect(relayState, &s.ServiceProvider.ServiceProvider) + if err != nil { + return nil, err + } + return idp.Redirect(redirectURL.String()) + } + if binding == saml.HTTPPostBinding { + doc := etree.NewDocument() + doc.SetRoot(authReq.Element()) + reqBuf, err := doc.WriteToBytes() + if err != nil { + return nil, err + } + encodedReqBuf := base64.StdEncoding.EncodeToString(reqBuf) + return idp.Form(authReq.Destination, + map[string]string{ + "SAMLRequest": encodedReqBuf, + "RelayState": relayState, + }) + } + return nil, zerrors.ThrowInvalidArgument(nil, "SAML-Eoi24", "Errors.Intent.Invalid") } diff --git a/internal/idp/session.go b/internal/idp/session.go index fc593eb820..d0df3415bf 100644 --- a/internal/idp/session.go +++ b/internal/idp/session.go @@ -7,12 +7,29 @@ import ( // Session is the minimal implementation for a session of a 3rd party authentication [Provider] type Session interface { - GetAuth(ctx context.Context) (content string, redirect bool) + GetAuth(ctx context.Context) (Auth, error) PersistentParameters() map[string]any FetchUser(ctx context.Context) (User, error) ExpiresAt() time.Time } +type Auth interface { + auth() +} + +type RedirectAuth struct { + RedirectURL string +} + +func (r *RedirectAuth) auth() {} + +type FormAuth struct { + URL string + Fields map[string]string +} + +func (f *FormAuth) auth() {} + // SessionSupportsMigration is an optional extension to the Session interface. // It can be implemented to support migrating users, were the initial external id has changed because of a migration of the Provider type. // E.g. when a user was linked on a generic OIDC provider and this provider has now been migrated to an AzureAD provider. @@ -22,10 +39,13 @@ type SessionSupportsMigration interface { RetrievePreviousID() (previousID string, err error) } -func Redirect(redirectURL string) (string, bool) { - return redirectURL, true +func Redirect(redirectURL string) (*RedirectAuth, error) { + return &RedirectAuth{RedirectURL: redirectURL}, nil } -func Form(html string) (string, bool) { - return html, false +func Form(url string, fields map[string]string) (*FormAuth, error) { + return &FormAuth{ + URL: url, + Fields: fields, + }, nil } diff --git a/proto/zitadel/user/v2/idp.proto b/proto/zitadel/user/v2/idp.proto index 73e633fb67..828a035c29 100644 --- a/proto/zitadel/user/v2/idp.proto +++ b/proto/zitadel/user/v2/idp.proto @@ -162,3 +162,21 @@ message IDPLink { } ]; } + +message FormData { + // The URL to which the form should be submitted using the POST method. + string url = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://idp.com/saml/v2/acs\""; + } + ]; + // The form fields to be submitted. + // Each field is represented as a key-value pair, where the key is the field / input name + // and the value is the field / input value. + // All fields need to be submitted as is and as input type "text". + map fields = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"relayState\":\"state\",\"SAMLRequest\":\"asjfkj3ir2fj248=\"}"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 79f66266bc..349f3c6c54 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -2895,11 +2895,15 @@ message StartIdentityProviderIntentResponse{ description: "IDP Intent information" } ]; + // POST call information + // Deprecated: Use form_data instead bytes post_form = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "POST call information" } ]; + // Data for a form POST call + FormData form_data = 5; } } diff --git a/proto/zitadel/user/v2beta/idp.proto b/proto/zitadel/user/v2beta/idp.proto index 7d58ec5363..237c8de114 100644 --- a/proto/zitadel/user/v2beta/idp.proto +++ b/proto/zitadel/user/v2beta/idp.proto @@ -162,3 +162,21 @@ message IDPLink { } ]; } + +message FormData { + // The URL to which the form should be submitted using the POST method. + string url = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"https://idp.com/saml/v2/acs\""; + } + ]; + // The form fields to be submitted. + // Each field is represented as a key-value pair, where the key is the field / input name + // and the value is the field / input value. + // All fields need to be submitted as is and as input type "text". + map fields = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "{\"relayState\":\"state\",\"SAMLRequest\":\"asjfkj3ir2fj248=\"}"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index f877252f51..bcb091abf2 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -1788,22 +1788,23 @@ message StartIdentityProviderIntentRequest{ message StartIdentityProviderIntentResponse{ zitadel.object.v2beta.Details details = 1; oneof next_step { + // URL to which the client should redirect string auth_url = 2 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "URL to which the client should redirect" example: "\"https://accounts.google.com/o/oauth2/v2/auth?client_id=clientID&callback=https%3A%2F%2Fzitadel.cloud%2Fidps%2Fcallback\""; } ]; - IDPIntent idp_intent = 3 [ - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "IDP Intent information" - } - ]; + // IDP Intent information + IDPIntent idp_intent = 3; + // POST call information + // Deprecated: Use form_data instead bytes post_form = 4 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { description: "POST call information" } ]; + // Data for a form POST call + FormData form_data = 5; } } From fce9e770ac9d24544fcbe3e842ff85c91e9d3d5f Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Wed, 2 Jul 2025 09:34:19 +0200 Subject: [PATCH 112/123] feat: App Keys API v2 (#10140) # Which Problems Are Solved This PR *partially* addresses #9450 . Specifically, it implements the resource based API for app keys. This PR, together with https://github.com/zitadel/zitadel/pull/10077 completes #9450 . # How the Problems Are Solved - Implementation of the following endpoints: `CreateApplicationKey`, `DeleteApplicationKey`, `GetApplicationKey`, `ListApplicationKeys` - `ListApplicationKeys` can filter by project, app or organization ID. Sorting is also possible according to some criteria. - All endpoints use permissions V2 # TODO - [x] Deprecate old endpoints # Additional Context Closes #9450 --- internal/api/grpc/app/v2beta/app_key.go | 47 ++++ .../api/grpc/app/v2beta/convert/api_app.go | 38 +++ .../grpc/app/v2beta/convert/api_app_test.go | 69 +++++ .../api/grpc/app/v2beta/convert/convert.go | 97 +++++++ .../grpc/app/v2beta/convert/convert_test.go | 184 +++++++++++++ .../v2beta/integration_test/app_key_test.go | 206 ++++++++++++++ .../app/v2beta/integration_test/app_test.go | 32 +-- .../app/v2beta/integration_test/query_test.go | 251 +++++++++++++++++- .../v2beta/integration_test/server_test.go | 25 +- internal/api/grpc/app/v2beta/query.go | 39 +++ .../project_application_converter.go | 4 +- internal/command/project_application_key.go | 13 + .../command/project_application_key_test.go | 86 ++++-- internal/domain/mock/permission.go | 22 ++ internal/query/authn_key.go | 16 ++ internal/query/authn_key_test.go | 8 + proto/zitadel/app/v2beta/app.proto | 27 ++ proto/zitadel/app/v2beta/app_service.proto | 246 +++++++++++++++-- proto/zitadel/management.proto | 9 + 19 files changed, 1350 insertions(+), 69 deletions(-) create mode 100644 internal/api/grpc/app/v2beta/app_key.go create mode 100644 internal/api/grpc/app/v2beta/integration_test/app_key_test.go create mode 100644 internal/domain/mock/permission.go diff --git a/internal/api/grpc/app/v2beta/app_key.go b/internal/api/grpc/app/v2beta/app_key.go new file mode 100644 index 0000000000..8c0c1989b2 --- /dev/null +++ b/internal/api/grpc/app/v2beta/app_key.go @@ -0,0 +1,47 @@ +package app + +import ( + "context" + "strings" + + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func (s *Server) CreateApplicationKey(ctx context.Context, req *app.CreateApplicationKeyRequest) (*app.CreateApplicationKeyResponse, error) { + domainReq := convert.CreateAPIClientKeyRequestToDomain(req) + + appKey, err := s.command.AddApplicationKey(ctx, domainReq, "") + if err != nil { + return nil, err + } + + keyDetails, err := appKey.Detail() + if err != nil { + return nil, err + } + + return &app.CreateApplicationKeyResponse{ + Id: appKey.KeyID, + CreationDate: timestamppb.New(appKey.ChangeDate), + KeyDetails: keyDetails, + }, nil +} + +func (s *Server) DeleteApplicationKey(ctx context.Context, req *app.DeleteApplicationKeyRequest) (*app.DeleteApplicationKeyResponse, error) { + deletionDetails, err := s.command.RemoveApplicationKey(ctx, + strings.TrimSpace(req.GetProjectId()), + strings.TrimSpace(req.GetApplicationId()), + strings.TrimSpace(req.GetId()), + strings.TrimSpace(req.GetOrganizationId()), + ) + if err != nil { + return nil, err + } + + return &app.DeleteApplicationKeyResponse{ + DeletionDate: timestamppb.New(deletionDetails.EventDate), + }, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app.go b/internal/api/grpc/app/v2beta/convert/api_app.go index bad76ab0d5..4900d534cb 100644 --- a/internal/api/grpc/app/v2beta/convert/api_app.go +++ b/internal/api/grpc/app/v2beta/convert/api_app.go @@ -1,6 +1,8 @@ package convert import ( + "strings" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" @@ -58,3 +60,39 @@ func apiAuthMethodTypeToPb(methodType domain.APIAuthMethodType) app.APIAuthMetho return app.APIAuthMethodType_API_AUTH_METHOD_TYPE_BASIC } } + +func GetApplicationKeyQueriesRequestToDomain(orgID, projectID, appID string) ([]query.SearchQuery, error) { + var searchQueries []query.SearchQuery + + orgID, projectID, appID = strings.TrimSpace(orgID), strings.TrimSpace(projectID), strings.TrimSpace(appID) + + if orgID != "" { + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(orgID) + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, resourceOwner) + } + + if projectID != "" { + aggregateID, err := query.NewAuthNKeyAggregateIDQuery(projectID) + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, aggregateID) + } + + if appID != "" { + objectID, err := query.NewAuthNKeyObjectIDQuery(appID) + + if err != nil { + return nil, err + } + + searchQueries = append(searchQueries, objectID) + } + + return searchQueries, nil +} diff --git a/internal/api/grpc/app/v2beta/convert/api_app_test.go b/internal/api/grpc/app/v2beta/convert/api_app_test.go index 9f15c3df76..dcb87d712f 100644 --- a/internal/api/grpc/app/v2beta/convert/api_app_test.go +++ b/internal/api/grpc/app/v2beta/convert/api_app_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" @@ -147,3 +148,71 @@ func Test_apiAuthMethodTypeToPb(t *testing.T) { }) } } +func TestGetApplicationKeyQueriesRequestToDomain(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + inputOrgID string + inputProjectID string + inputAppID string + + expectedQueriesLength int + }{ + { + testName: "all IDs provided", + inputOrgID: "org-1", + inputProjectID: "proj-1", + inputAppID: "app-1", + expectedQueriesLength: 3, + }, + { + testName: "only org ID", + inputOrgID: "org-1", + inputProjectID: " ", + inputAppID: "", + expectedQueriesLength: 1, + }, + { + testName: "only project ID", + inputOrgID: "", + inputProjectID: "proj-1", + inputAppID: " ", + expectedQueriesLength: 1, + }, + { + testName: "only app ID", + inputOrgID: " ", + inputProjectID: "", + inputAppID: "app-1", + expectedQueriesLength: 1, + }, + { + testName: "empty IDs", + inputOrgID: " ", + inputProjectID: " ", + inputAppID: " ", + expectedQueriesLength: 0, + }, + { + testName: "with spaces", + inputOrgID: " org-1 ", + inputProjectID: " proj-1 ", + inputAppID: " app-1 ", + expectedQueriesLength: 3, + }, + } + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // When + got, err := GetApplicationKeyQueriesRequestToDomain(tc.inputOrgID, tc.inputProjectID, tc.inputAppID) + + // Then + require.NoError(t, err) + + assert.Len(t, got, tc.expectedQueriesLength) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/convert/convert.go b/internal/api/grpc/app/v2beta/convert/convert.go index c732b3a0c5..a0a1d5ef05 100644 --- a/internal/api/grpc/app/v2beta/convert/convert.go +++ b/internal/api/grpc/app/v2beta/convert/convert.go @@ -2,6 +2,7 @@ package convert import ( "net/url" + "strings" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -9,6 +10,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" @@ -163,3 +165,98 @@ func appQueryToModel(appQuery *app.ApplicationSearchFilter) (query.SearchQuery, return nil, zerrors.ThrowInvalidArgument(nil, "CONV-z2mAGy", "List.Query.Invalid") } } + +func CreateAPIClientKeyRequestToDomain(key *app.CreateApplicationKeyRequest) *domain.ApplicationKey { + return &domain.ApplicationKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: strings.TrimSpace(key.GetProjectId()), + }, + ExpirationDate: key.GetExpirationDate().AsTime(), + Type: domain.AuthNKeyTypeJSON, + ApplicationID: strings.TrimSpace(key.GetAppId()), + } +} + +func ListApplicationKeysRequestToDomain(sysDefaults systemdefaults.SystemDefaults, req *app.ListApplicationKeysRequest) (*query.AuthNKeySearchQueries, error) { + var queries []query.SearchQuery + + switch req.GetResourceId().(type) { + case *app.ListApplicationKeysRequest_ApplicationId: + object, err := query.NewAuthNKeyObjectIDQuery(strings.TrimSpace(req.GetApplicationId())) + if err != nil { + return nil, err + } + queries = append(queries, object) + case *app.ListApplicationKeysRequest_OrganizationId: + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(strings.TrimSpace(req.GetOrganizationId())) + if err != nil { + return nil, err + } + queries = append(queries, resourceOwner) + case *app.ListApplicationKeysRequest_ProjectId: + aggregate, err := query.NewAuthNKeyAggregateIDQuery(strings.TrimSpace(req.GetProjectId())) + if err != nil { + return nil, err + } + queries = append(queries, aggregate) + case nil: + + default: + return nil, zerrors.ThrowInvalidArgument(nil, "CONV-t3ENme", "unexpected resource id") + } + + offset, limit, asc, err := filter.PaginationPbToQuery(sysDefaults, req.GetPagination()) + if err != nil { + return nil, err + } + + return &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: appKeysSortingToColumn(req.GetSortingColumn()), + }, + + Queries: queries, + }, nil +} + +func appKeysSortingToColumn(sortingCriteria app.ApplicationKeysSorting) query.Column { + switch sortingCriteria { + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_PROJECT_ID: + return query.AuthNKeyColumnAggregateID + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE: + return query.AuthNKeyColumnCreationDate + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION: + return query.AuthNKeyColumnExpiration + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID: + return query.AuthNKeyColumnResourceOwner + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_TYPE: + return query.AuthNKeyColumnType + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_APPLICATION_ID: + return query.AuthNKeyColumnObjectID + case app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_ID: + fallthrough + default: + return query.AuthNKeyColumnID + } +} + +func ApplicationKeysToPb(keys []*query.AuthNKey) []*app.ApplicationKey { + pbAppKeys := make([]*app.ApplicationKey, len(keys)) + + for i, k := range keys { + pbKey := &app.ApplicationKey{ + Id: k.ID, + ApplicationId: k.ApplicationID, + ProjectId: k.AggregateID, + CreationDate: timestamppb.New(k.CreationDate), + OrganizationId: k.ResourceOwner, + ExpirationDate: timestamppb.New(k.Expiration), + } + pbAppKeys[i] = pbKey + } + + return pbAppKeys +} diff --git a/internal/api/grpc/app/v2beta/convert/convert_test.go b/internal/api/grpc/app/v2beta/convert/convert_test.go index 5835691d43..8715d2a5dd 100644 --- a/internal/api/grpc/app/v2beta/convert/convert_test.go +++ b/internal/api/grpc/app/v2beta/convert/convert_test.go @@ -518,3 +518,187 @@ func TestAppQueryToModel(t *testing.T) { }) } } + +func TestListApplicationKeysRequestToDomain(t *testing.T) { + t.Parallel() + + resourceOwnerQuery, err := query.NewAuthNKeyResourceOwnerQuery("org1") + require.NoError(t, err) + + projectIDQuery, err := query.NewAuthNKeyAggregateIDQuery("project1") + require.NoError(t, err) + + appIDQuery, err := query.NewAuthNKeyObjectIDQuery("app1") + require.NoError(t, err) + + sysDefaults := systemdefaults.SystemDefaults{DefaultQueryLimit: 100, MaxQueryLimit: 150} + + tt := []struct { + name string + req *app.ListApplicationKeysRequest + + expectedResult *query.AuthNKeySearchQueries + expectedError error + }{ + { + name: "invalid pagination limit", + req: &app.ListApplicationKeysRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true, Limit: uint32(sysDefaults.MaxQueryLimit + 1)}, + }, + expectedResult: nil, + expectedError: zerrors.ThrowInvalidArgumentf(fmt.Errorf("given: %d, allowed: %d", sysDefaults.MaxQueryLimit+1, sysDefaults.MaxQueryLimit), "QUERY-4M0fs", "Errors.Query.LimitExceeded"), + }, + { + name: "empty request", + req: &app.ListApplicationKeysRequest{ + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: nil, + }, + }, + { + name: "only organization id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_OrganizationId{OrganizationId: "org1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + resourceOwnerQuery, + }, + }, + }, + { + name: "only project id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: "project1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + projectIDQuery, + }, + }, + }, + { + name: "only application id", + req: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: "app1"}, + Pagination: &filter_pb_v2.PaginationRequest{Asc: true}, + }, + expectedResult: &query.AuthNKeySearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: 0, + Limit: 100, + Asc: true, + SortingColumn: query.AuthNKeyColumnID, + }, + Queries: []query.SearchQuery{ + appIDQuery, + }, + }, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + result, err := ListApplicationKeysRequestToDomain(sysDefaults, tc.req) + + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + +func TestApplicationKeysToPb(t *testing.T) { + t.Parallel() + + now := time.Now() + + tt := []struct { + name string + input []*query.AuthNKey + expected []*app.ApplicationKey + }{ + { + name: "multiple keys", + input: []*query.AuthNKey{ + { + ID: "key1", + AggregateID: "project1", + ApplicationID: "app1", + CreationDate: now, + ResourceOwner: "org1", + Expiration: now.Add(24 * time.Hour), + Type: domain.AuthNKeyTypeJSON, + }, + { + ID: "key2", + AggregateID: "project2", + ApplicationID: "app1", + CreationDate: now.Add(-time.Hour), + ResourceOwner: "org2", + Expiration: now.Add(48 * time.Hour), + Type: domain.AuthNKeyTypeNONE, + }, + }, + expected: []*app.ApplicationKey{ + { + Id: "key1", + ApplicationId: "app1", + ProjectId: "project1", + CreationDate: timestamppb.New(now), + OrganizationId: "org1", + ExpirationDate: timestamppb.New(now.Add(24 * time.Hour)), + }, + { + Id: "key2", + ApplicationId: "app1", + ProjectId: "project2", + CreationDate: timestamppb.New(now.Add(-time.Hour)), + OrganizationId: "org2", + ExpirationDate: timestamppb.New(now.Add(48 * time.Hour)), + }, + }, + }, + { + name: "empty slice", + input: []*query.AuthNKey{}, + expected: []*app.ApplicationKey{}, + }, + { + name: "nil input", + input: nil, + expected: []*app.ApplicationKey{}, + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := ApplicationKeysToPb(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_key_test.go b/internal/api/grpc/app/v2beta/integration_test/app_key_test.go new file mode 100644 index 0000000000..7c3c886cff --- /dev/null +++ b/internal/api/grpc/app/v2beta/integration_test/app_key_test.go @@ -0,0 +1,206 @@ +//go:build integration + +package app_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" +) + +func TestCreateApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + creationRequest *app.CreateApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app id is not found should return failed precondition", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: gofakeit.UUID(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + expectedErrorType: codes.FailedPrecondition, + }, + { + testName: "when CreateAPIApp request is valid should create app and return no error", + inputCtx: IAMOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app key generation should return permission error", + inputCtx: LoginUserCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + expectedErrorType: codes.PermissionDenied, + }, + + // OrgOwner + { + testName: "when user is OrgOwner app key request should succeed", + inputCtx: OrgOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + + // ProjectOwner + { + testName: "when user is ProjectOwner app key request should succeed", + inputCtx: projectOwnerCtx, + creationRequest: &app.CreateApplicationKeyRequest{ + ProjectId: p.GetId(), + AppId: createdApp.GetAppId(), + ExpirationDate: timestamppb.New(time.Now().AddDate(0, 0, 1).UTC()), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + res, err := instance.Client.AppV2Beta.CreateApplicationKey(tc.inputCtx, tc.creationRequest) + + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotZero(t, res.GetId()) + assert.NotZero(t, res.GetCreationDate()) + assert.NotZero(t, res.GetKeyDetails()) + } + }) + } +} + +func TestDeleteApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + t.Parallel() + + tt := []struct { + testName string + deletionRequest func(ttt *testing.T) *app.DeleteApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + }{ + { + testName: "when app key ID is not found should return not found error", + inputCtx: IAMOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + return &app.DeleteApplicationKeyRequest{ + Id: gofakeit.UUID(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + expectedErrorType: codes.NotFound, + }, + { + testName: "when valid app key ID should delete successfully", + inputCtx: IAMOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + + // LoginUser + { + testName: "when user has no project.app.write permission for app key deletion should return permission error", + inputCtx: LoginUserCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + expectedErrorType: codes.PermissionDenied, + }, + + // ProjectOwner + { + testName: "when user is OrgOwner API request should succeed", + inputCtx: projectOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + + // OrganizationOwner + { + testName: "when user is OrgOwner app key deletion request should succeed", + inputCtx: OrgOwnerCtx, + deletionRequest: func(ttt *testing.T) *app.DeleteApplicationKeyRequest { + createdAppKey := createAppKey(ttt, IAMOwnerCtx, instance, p.GetId(), createdApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + return &app.DeleteApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + ApplicationId: createdApp.GetAppId(), + } + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + deletionReq := tc.deletionRequest(t) + + // When + res, err := instance.Client.AppV2Beta.DeleteApplicationKey(tc.inputCtx, deletionReq) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + assert.NotEmpty(t, res.GetDeletionDate()) + } + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/app_test.go b/internal/api/grpc/app/v2beta/integration_test/app_test.go index 1ba46987cf..67e59aa91d 100644 --- a/internal/api/grpc/app/v2beta/integration_test/app_test.go +++ b/internal/api/grpc/app/v2beta/integration_test/app_test.go @@ -1,6 +1,6 @@ //go:build integration -package instance_test +package app_test import ( "context" @@ -653,9 +653,9 @@ func TestUpdateApplication_WithDifferentPermissions(t *testing.T) { }) require.Nil(t, appNameChangeErr) - appForAPIConfigChangeForProjectOwner := createAPIApp(t, p.GetId()) - appForAPIConfigChangeForOrgOwner := createAPIApp(t, p.GetId()) - appForAPIConfigChangeForLoginUser := createAPIApp(t, p.GetId()) + appForAPIConfigChangeForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appForAPIConfigChangeForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appForAPIConfigChangeForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) appForOIDCConfigChangeForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) appForOIDCConfigChangeForOrgOwner := createOIDCApp(t, baseURI, p.GetId()) @@ -914,9 +914,9 @@ func TestDeleteApplication(t *testing.T) { func TestDeleteApplication_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - appToDeleteForLoginUser := createAPIApp(t, p.GetId()) - appToDeleteForProjectOwner := createAPIApp(t, p.GetId()) - appToDeleteForOrgOwner := createAPIApp(t, p.GetId()) + appToDeleteForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeleteForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeleteForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) t.Parallel() tt := []struct { @@ -1035,9 +1035,9 @@ func TestDeactivateApplication(t *testing.T) { func TestDeactivateApplication_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - appToDeactivateForLoginUser := createAPIApp(t, p.GetId()) - appToDeactivateForPrjectOwner := createAPIApp(t, p.GetId()) - appToDeactivateForOrgOwner := createAPIApp(t, p.GetId()) + appToDeactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeactivateForPrjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + appToDeactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) t.Parallel() @@ -1162,13 +1162,13 @@ func TestReactivateApplication(t *testing.T) { func TestReactivateApplication_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - appToReactivateForLoginUser := createAPIApp(t, p.GetId()) + appToReactivateForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, appToReactivateForLoginUser, p.GetId()) - appToReactivateForProjectOwner := createAPIApp(t, p.GetId()) + appToReactivateForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, appToReactivateForProjectOwner, p.GetId()) - appToReactivateForOrgOwner := createAPIApp(t, p.GetId()) + appToReactivateForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, appToReactivateForOrgOwner, p.GetId()) t.Parallel() @@ -1342,9 +1342,9 @@ func TestRegenerateClientSecret(t *testing.T) { func TestRegenerateClientSecret_WithDifferentPermissions(t *testing.T) { p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) - apiAppToRegenForLoginUser := createAPIApp(t, p.GetId()) - apiAppToRegenForProjectOwner := createAPIApp(t, p.GetId()) - apiAppToRegenForOrgOwner := createAPIApp(t, p.GetId()) + apiAppToRegenForLoginUser := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + apiAppToRegenForProjectOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + apiAppToRegenForOrgOwner := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) oidcAppToRegenForLoginUser := createOIDCApp(t, baseURI, p.GetId()) oidcAppToRegenForProjectOwner := createOIDCApp(t, baseURI, p.GetId()) diff --git a/internal/api/grpc/app/v2beta/integration_test/query_test.go b/internal/api/grpc/app/v2beta/integration_test/query_test.go index 578fcec138..4f6679da7f 100644 --- a/internal/api/grpc/app/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/app/v2beta/integration_test/query_test.go @@ -1,6 +1,6 @@ //go:build integration -package instance_test +package app_test import ( "context" @@ -165,9 +165,9 @@ func TestListApplications(t *testing.T) { t.Parallel() - createdApiApp, apiAppName := createAPIAppWithName(t, p.GetId()) + createdApiApp, apiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId()) - createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, p.GetId()) + createdDeactivatedApiApp, deactivatedApiAppName := createAPIAppWithName(t, IAMOwnerCtx, instance, p.GetId()) deactivateApp(t, createdDeactivatedApiApp, p.GetId()) _, createdSAMLApp, samlAppName := createSAMLAppWithName(t, gofakeit.URL(), p.GetId()) @@ -573,3 +573,248 @@ func TestListApplications_WithPermissionV2(t *testing.T) { }) } } + +func TestGetApplicationKey(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + createdApiApp := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + createdAppKey := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp.GetAppId(), time.Now().AddDate(0, 0, 1)) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.GetApplicationKeyRequest + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeyID string + }{ + { + testName: "when unknown app ID should return not found error", + inputCtx: IAMOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: gofakeit.Sentence(2), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when user has no permission should return membership not found error", + inputCtx: NoPermissionCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + }, + + expectedErrorType: codes.NotFound, + }, + { + testName: "when providing API app ID should return valid API app result", + inputCtx: projectOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + { + testName: "when user is OrgOwner should return request key", + inputCtx: OrgOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + ProjectId: p.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + { + testName: "when user is IAMOwner should return request key", + inputCtx: OrgOwnerCtx, + inputRequest: &app.GetApplicationKeyRequest{ + Id: createdAppKey.GetId(), + OrganizationId: instance.DefaultOrg.GetId(), + }, + + expectedAppKeyID: createdAppKey.GetId(), + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 30*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.GetApplicationKey(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(t, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + + assert.Equal(t, tc.expectedAppKeyID, res.GetId()) + assert.NotEmpty(t, res.GetCreationDate()) + assert.NotEmpty(t, res.GetExpirationDate()) + } + }, retryDuration, tick) + }) + } +} + +func TestListApplicationKeys(t *testing.T) { + p, projectOwnerCtx := getProjectAndProjectContext(t, instance, IAMOwnerCtx) + + createdApiApp1 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + createdApiApp2 := createAPIApp(t, IAMOwnerCtx, instance, p.GetId()) + + tomorrow := time.Now().AddDate(0, 0, 1) + in2Days := tomorrow.AddDate(0, 0, 1) + in3Days := in2Days.AddDate(0, 0, 1) + + appKey1 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in2Days) + appKey2 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), in3Days) + appKey3 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp1.GetAppId(), tomorrow) + appKey4 := createAppKey(t, IAMOwnerCtx, instance, p.GetId(), createdApiApp2.GetAppId(), tomorrow) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationKeysRequest + deps func() (projectID, applicationID, organizationID string) + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeysIDs []string + }{ + { + testName: "when sorting by expiration date should return keys sorted by expiration date ascending", + inputCtx: LoginUserCtx, + inputRequest: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()}, + Pagination: &filter.PaginationRequest{Asc: true}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION, + }, + expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()}, + }, + { + testName: "when sorting by creation date should return keys sorted by creation date descending", + inputCtx: IAMOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + ResourceId: &app.ListApplicationKeysRequest_ProjectId{ProjectId: p.GetId()}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE, + }, + expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()}, + }, + { + testName: "when filtering by app ID should return keys matching app ID sorted by ID", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()}, + }, + expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instance.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs)) + + for i, k := range res.GetKeys() { + assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId()) + } + } + }, retryDuration, tick) + }) + } +} + +func TestListApplicationKeys_WithPermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeIAMOwner) + loginUserCtx := instancePermissionV2.WithAuthorization(context.Background(), integration.UserTypeLogin) + p, projectOwnerCtx := getProjectAndProjectContext(t, instancePermissionV2, iamOwnerCtx) + + createdApiApp1 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId()) + createdApiApp2 := createAPIApp(t, iamOwnerCtx, instancePermissionV2, p.GetId()) + + tomorrow := time.Now().AddDate(0, 0, 1) + in2Days := tomorrow.AddDate(0, 0, 1) + in3Days := in2Days.AddDate(0, 0, 1) + + appKey1 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in2Days) + appKey2 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), in3Days) + appKey3 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp1.GetAppId(), tomorrow) + appKey4 := createAppKey(t, iamOwnerCtx, instancePermissionV2, p.GetId(), createdApiApp2.GetAppId(), tomorrow) + + t.Parallel() + + tt := []struct { + testName string + inputRequest *app.ListApplicationKeysRequest + deps func() (projectID, applicationID, organizationID string) + inputCtx context.Context + + expectedErrorType codes.Code + expectedAppKeysIDs []string + }{ + { + testName: "when sorting by expiration date should return keys sorted by expiration date ascending", + inputCtx: loginUserCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_EXPIRATION, + }, + expectedAppKeysIDs: []string{appKey3.GetId(), appKey4.GetId(), appKey1.GetId(), appKey2.GetId()}, + }, + { + testName: "when sorting by creation date should return keys sorted by creation date descending", + inputCtx: iamOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + SortingColumn: app.ApplicationKeysSorting_APPLICATION_KEYS_SORT_BY_CREATION_DATE, + }, + expectedAppKeysIDs: []string{appKey4.GetId(), appKey3.GetId(), appKey2.GetId(), appKey1.GetId()}, + }, + { + testName: "when filtering by app ID should return keys matching app ID sorted by ID", + inputCtx: projectOwnerCtx, + inputRequest: &app.ListApplicationKeysRequest{ + Pagination: &filter.PaginationRequest{Asc: true}, + ResourceId: &app.ListApplicationKeysRequest_ApplicationId{ApplicationId: createdApiApp1.GetAppId()}, + }, + expectedAppKeysIDs: []string{appKey1.GetId(), appKey2.GetId(), appKey3.GetId()}, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // t.Parallel() + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(tc.inputCtx, 5*time.Second) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + // When + res, err := instancePermissionV2.Client.AppV2Beta.ListApplicationKeys(tc.inputCtx, tc.inputRequest) + + // Then + require.Equal(ttt, tc.expectedErrorType, status.Code(err)) + if tc.expectedErrorType == codes.OK { + require.Len(ttt, res.GetKeys(), len(tc.expectedAppKeysIDs)) + + for i, k := range res.GetKeys() { + assert.Equal(ttt, tc.expectedAppKeysIDs[i], k.GetId()) + } + } + }, retryDuration, tick) + }) + } +} diff --git a/internal/api/grpc/app/v2beta/integration_test/server_test.go b/internal/api/grpc/app/v2beta/integration_test/server_test.go index 6618ab0616..8ba012c18b 100644 --- a/internal/api/grpc/app/v2beta/integration_test/server_test.go +++ b/internal/api/grpc/app/v2beta/integration_test/server_test.go @@ -1,6 +1,6 @@ //go:build integration -package instance_test +package app_test import ( "context" @@ -13,6 +13,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/integration" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" @@ -150,14 +151,14 @@ func createOIDCApp(t *testing.T, baseURI, projctID string) *app.CreateApplicatio return app } -func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicationResponse, string) { +func createAPIAppWithName(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) (*app.CreateApplicationResponse, string) { appName := gofakeit.AppName() reqForAPIAppCreation := &app.CreateApplicationRequest_ApiRequest{ ApiRequest: &app.CreateAPIApplicationRequest{AuthMethodType: app.APIAuthMethodType_API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT}, } - appForAPIConfigChange, appAPIConfigChangeErr := instance.Client.AppV2Beta.CreateApplication(IAMOwnerCtx, &app.CreateApplicationRequest{ + appForAPIConfigChange, appAPIConfigChangeErr := inst.Client.AppV2Beta.CreateApplication(ctx, &app.CreateApplicationRequest{ ProjectId: projectID, Name: appName, CreationRequestType: reqForAPIAppCreation, @@ -167,8 +168,8 @@ func createAPIAppWithName(t *testing.T, projectID string) (*app.CreateApplicatio return appForAPIConfigChange, appName } -func createAPIApp(t *testing.T, projectID string) *app.CreateApplicationResponse { - res, _ := createAPIAppWithName(t, projectID) +func createAPIApp(t *testing.T, ctx context.Context, inst *integration.Instance, projectID string) *app.CreateApplicationResponse { + res, _ := createAPIAppWithName(t, ctx, inst, projectID) return res } @@ -203,3 +204,17 @@ func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instan assert.True(tt, f.PermissionCheckV2.GetEnabled()) }, retryDuration, tick, "timed out waiting for ensuring instance feature") } + +func createAppKey(t *testing.T, ctx context.Context, inst *integration.Instance, projectID, appID string, expirationDate time.Time) *app.CreateApplicationKeyResponse { + res, err := inst.Client.AppV2Beta.CreateApplicationKey(ctx, + &app.CreateApplicationKeyRequest{ + AppId: appID, + ProjectId: projectID, + ExpirationDate: timestamppb.New(expirationDate.UTC()), + }, + ) + + require.Nil(t, err) + + return res +} diff --git a/internal/api/grpc/app/v2beta/query.go b/internal/api/grpc/app/v2beta/query.go index add8af83e6..2926884520 100644 --- a/internal/api/grpc/app/v2beta/query.go +++ b/internal/api/grpc/app/v2beta/query.go @@ -2,9 +2,13 @@ package app import ( "context" + "strings" + + "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta/convert" filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" ) @@ -35,3 +39,38 @@ func (s *Server) ListApplications(ctx context.Context, req *app.ListApplications Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), }, nil } + +func (s *Server) GetApplicationKey(ctx context.Context, req *app.GetApplicationKeyRequest) (*app.GetApplicationKeyResponse, error) { + queries, err := convert.GetApplicationKeyQueriesRequestToDomain(req.GetOrganizationId(), req.GetProjectId(), req.GetApplicationId()) + if err != nil { + return nil, err + } + + key, err := s.query.GetAuthNKeyByIDWithPermission(ctx, true, strings.TrimSpace(req.GetId()), s.checkPermission, queries...) + if err != nil { + return nil, err + } + + return &app.GetApplicationKeyResponse{ + Id: key.ID, + CreationDate: timestamppb.New(key.CreationDate), + ExpirationDate: timestamppb.New(key.Expiration), + }, nil +} + +func (s *Server) ListApplicationKeys(ctx context.Context, req *app.ListApplicationKeysRequest) (*app.ListApplicationKeysResponse, error) { + queries, err := convert.ListApplicationKeysRequestToDomain(s.systemDefaults, req) + if err != nil { + return nil, err + } + + res, err := s.query.SearchAuthNKeys(ctx, queries, query.JoinFilterUnspecified, s.checkPermission) + if err != nil { + return nil, err + } + + return &app.ListApplicationKeysResponse{ + Keys: convert.ApplicationKeysToPb(res.AuthNKeys), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, res.SearchResponse), + }, nil +} diff --git a/internal/api/grpc/management/project_application_converter.go b/internal/api/grpc/management/project_application_converter.go index 186cedc933..fa31565445 100644 --- a/internal/api/grpc/management/project_application_converter.go +++ b/internal/api/grpc/management/project_application_converter.go @@ -177,7 +177,7 @@ func AddAPIClientKeyRequestToDomain(key *mgmt_pb.AddAppKeyRequest) *domain.Appli } func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKeysRequest) (*query.AuthNKeySearchQueries, error) { - resourcOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID) + resourceOwner, err := query.NewAuthNKeyResourceOwnerQuery(authz.GetCtxData(ctx).OrgID) if err != nil { return nil, err } @@ -197,7 +197,7 @@ func ListAPIClientKeysRequestToQuery(ctx context.Context, req *mgmt_pb.ListAppKe Asc: asc, }, Queries: []query.SearchQuery{ - resourcOwner, + resourceOwner, projectID, appID, }, diff --git a/internal/command/project_application_key.go b/internal/command/project_application_key.go index 519e9fc30a..47dacdd638 100644 --- a/internal/command/project_application_key.go +++ b/internal/command/project_application_key.go @@ -38,6 +38,11 @@ func (c *Commands) AddApplicationKey(ctx context.Context, key *domain.Applicatio if err != nil { return nil, err } + + if resourceOwner == "" { + resourceOwner = application.ResourceOwner + } + if !application.State.Exists() { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-sak25", "Errors.Project.App.NotFound") } @@ -59,6 +64,10 @@ func (c *Commands) addApplicationKey(ctx context.Context, key *domain.Applicatio return nil, err } + if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil { + return nil, err + } + if !keyWriteModel.KeysAllowed { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Dff54", "Errors.Project.App.AuthMethodNoPrivateKeyJWT") } @@ -110,6 +119,10 @@ func (c *Commands) RemoveApplicationKey(ctx context.Context, projectID, applicat return nil, zerrors.ThrowNotFound(nil, "COMMAND-4m77G", "Errors.Project.App.Key.NotFound") } + if err := c.checkPermissionUpdateApplication(ctx, keyWriteModel.ResourceOwner, keyWriteModel.AggregateID); err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, project.NewApplicationKeyRemovedEvent(ctx, ProjectAggregateFromWriteModel(&keyWriteModel.WriteModel), keyID)) if err != nil { return nil, err diff --git a/internal/command/project_application_key_test.go b/internal/command/project_application_key_test.go index 9fd46c75f3..3402cab507 100644 --- a/internal/command/project_application_key_test.go +++ b/internal/command/project_application_key_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/zitadel/zitadel/internal/domain" + permissionmock "github.com/zitadel/zitadel/internal/domain/mock" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/id" @@ -17,9 +18,10 @@ import ( func TestCommandSide_AddAPIApplicationKey(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator - keySize int + eventstore func(*testing.T) *eventstore.Eventstore + idGenerator id.Generator + keySize int + permissionCheckMock domain.PermissionCheck } type args struct { ctx context.Context @@ -39,9 +41,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "no aggregateid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -57,9 +58,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "no appid, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -77,10 +77,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { { name: "app not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), + eventstore: expectEventstore(expectFilter()), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -97,10 +95,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, }, { - name: "create key not allowed, precondition error", + name: "create key not allowed, precondition error 1", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -121,7 +118,8 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + permissionCheckMock: permissionmock.MockPermissionCheckOK(), }, args: args{ ctx: context.Background(), @@ -138,10 +136,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, }, { - name: "create key not allowed, precondition error", + name: "permission check failed", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewApplicationAddedEvent(context.Background(), @@ -162,8 +159,9 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), - keySize: 10, + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + keySize: 10, + permissionCheckMock: permissionmock.MockPermissionCheckErr(zerrors.ThrowPermissionDenied(nil, "mock.err", "mock permission check failed")), }, args: args{ ctx: context.Background(), @@ -175,6 +173,47 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { }, resourceOwner: "org1", }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "create key not allowed, precondition error 2", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewApplicationAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "app", + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewAPIConfigAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "app1", + "client1@project", + "secret", + domain.APIAuthMethodTypeBasic), + ), + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "key1"), + keySize: 10, + permissionCheckMock: permissionmock.MockPermissionCheckOK(), + }, + args: args{ + ctx: context.Background(), + key: &domain.ApplicationKey{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "project1", + }, + ApplicationID: "app1", + }, + }, res: res{ err: zerrors.IsPreconditionFailed, }, @@ -183,9 +222,10 @@ func TestCommandSide_AddAPIApplicationKey(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, applicationKeySize: tt.fields.keySize, + checkPermission: tt.fields.permissionCheckMock, } got, err := r.AddApplicationKey(tt.args.ctx, tt.args.key, tt.args.resourceOwner) if tt.res.err == nil { diff --git a/internal/domain/mock/permission.go b/internal/domain/mock/permission.go new file mode 100644 index 0000000000..9a3c6478c9 --- /dev/null +++ b/internal/domain/mock/permission.go @@ -0,0 +1,22 @@ +package permissionmock + +import ( + "golang.org/x/net/context" + + "github.com/zitadel/zitadel/internal/domain" +) + +// MockPermissionCheckErr returns a permission check function that will fail +// and return the input error +func MockPermissionCheckErr(err error) domain.PermissionCheck { + return func(_ context.Context, _, _, _ string) error { + return err + } +} + +// MockPermissionCheckOK returns a permission check function that will succeed +func MockPermissionCheckOK() domain.PermissionCheck { + return func(_ context.Context, _, _, _ string) (err error) { + return nil + } +} diff --git a/internal/query/authn_key.go b/internal/query/authn_key.go index 5a1f49d63c..abda5f011e 100644 --- a/internal/query/authn_key.go +++ b/internal/query/authn_key.go @@ -98,6 +98,7 @@ type AuthNKey struct { ChangeDate time.Time ResourceOwner string Sequence uint64 + ApplicationID string Expiration time.Time Type domain.AuthNKeyType @@ -222,6 +223,19 @@ func (q *Queries) SearchAuthNKeysData(ctx context.Context, queries *AuthNKeySear return authNKeys, err } +func (q *Queries) GetAuthNKeyByIDWithPermission(ctx context.Context, shouldTriggerBulk bool, id string, permissionCheck domain.PermissionCheck, queries ...SearchQuery) (*AuthNKey, error) { + key, err := q.GetAuthNKeyByID(ctx, shouldTriggerBulk, id, queries...) + if err != nil { + return nil, err + } + + if err := appCheckPermission(ctx, key.ResourceOwner, key.AggregateID, permissionCheck); err != nil { + return nil, err + } + + return key, nil +} + func (q *Queries) GetAuthNKeyByID(ctx context.Context, shouldTriggerBulk bool, id string, queries ...SearchQuery) (key *AuthNKey, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -330,6 +344,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys AuthNKeyColumnSequence.identifier(), AuthNKeyColumnExpiration.identifier(), AuthNKeyColumnType.identifier(), + AuthNKeyColumnObjectID.identifier(), countColumn.identifier(), ).From(authNKeyTable.identifier()). PlaceholderFormat(sq.Dollar) @@ -348,6 +363,7 @@ func prepareAuthNKeysQuery() (sq.SelectBuilder, func(rows *sql.Rows) (*AuthNKeys &authNKey.Sequence, &authNKey.Expiration, &authNKey.Type, + &authNKey.ApplicationID, &count, ) if err != nil { diff --git a/internal/query/authn_key_test.go b/internal/query/authn_key_test.go index ce45185363..619ffaac8c 100644 --- a/internal/query/authn_key_test.go +++ b/internal/query/authn_key_test.go @@ -26,6 +26,7 @@ var ( ` projections.authn_keys2.sequence,` + ` projections.authn_keys2.expiration,` + ` projections.authn_keys2.type,` + + ` projections.authn_keys2.object_id,` + ` COUNT(*) OVER ()` + ` FROM projections.authn_keys2` prepareAuthNKeysCols = []string{ @@ -37,6 +38,7 @@ var ( "sequence", "expiration", "type", + "object_id", "count", } @@ -129,6 +131,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, }, ), @@ -147,6 +150,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, }, }, @@ -168,6 +172,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, { "id-2", @@ -178,6 +183,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { uint64(20211109), testNow, 1, + "app1", }, }, ), @@ -196,6 +202,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, { ID: "id-2", @@ -206,6 +213,7 @@ func Test_AuthNKeyPrepares(t *testing.T) { Sequence: 20211109, Expiration: testNow, Type: domain.AuthNKeyTypeJSON, + ApplicationID: "app1", }, }, }, diff --git a/proto/zitadel/app/v2beta/app.proto b/proto/zitadel/app/v2beta/app.proto index f85e3c021d..f108f3bacb 100644 --- a/proto/zitadel/app/v2beta/app.proto +++ b/proto/zitadel/app/v2beta/app.proto @@ -92,3 +92,30 @@ message ApplicationNameQuery { } ]; } + +enum ApplicationKeysSorting { + APPLICATION_KEYS_SORT_BY_ID = 0; + APPLICATION_KEYS_SORT_BY_PROJECT_ID = 1; + APPLICATION_KEYS_SORT_BY_APPLICATION_ID = 2; + APPLICATION_KEYS_SORT_BY_CREATION_DATE = 3; + APPLICATION_KEYS_SORT_BY_ORGANIZATION_ID = 4; + APPLICATION_KEYS_SORT_BY_EXPIRATION = 5; + APPLICATION_KEYS_SORT_BY_TYPE = 6; +} + +message ApplicationKey { + string id = 1; + string application_id = 2; + string project_id = 3; + google.protobuf.Timestamp creation_date = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + string organization_id = 5; + google.protobuf.Timestamp expiration_date = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/app/v2beta/app_service.proto b/proto/zitadel/app/v2beta/app_service.proto index a881022caa..61cde73696 100644 --- a/proto/zitadel/app/v2beta/app_service.proto +++ b/proto/zitadel/app/v2beta/app_service.proto @@ -114,8 +114,6 @@ service AppService { // // Create an application. The application can be OIDC, API or SAML type, based on the input. // - // The user needs to have project.app.write permission - // // Required permissions: // - project.app.write rpc CreateApplication(CreateApplicationRequest) returns (CreateApplicationResponse) { @@ -145,8 +143,6 @@ service AppService { // Changes the configuration of an OIDC, API or SAML type application, as well as // the application name, based on the input provided. // - // The user needs to have project.app.write permission - // // Required permissions: // - project.app.write rpc UpdateApplication(UpdateApplicationRequest) returns (UpdateApplicationResponse) { @@ -175,8 +171,6 @@ service AppService { // // Retrieves the application matching the provided ID. // - // The user needs to have project.app.read permission - // // Required permissions: // - project.app.read rpc GetApplication(GetApplicationRequest) returns (GetApplicationResponse) { @@ -203,9 +197,7 @@ service AppService { // Delete Application // // Deletes the application belonging to the input project and matching the provided - // application ID - // - // The user needs to have project.app.delete permission + // application ID. // // Required permissions: // - project.app.delete @@ -233,9 +225,7 @@ service AppService { // Deactivate Application // // Deactivates the application belonging to the input project and matching the provided - // application ID - // - // The user needs to have project.app.write permission + // application ID. // // Required permissions: // - project.app.write @@ -264,9 +254,7 @@ service AppService { // Reactivate Application // // Reactivates the application belonging to the input project and matching the provided - // application ID - // - // The user needs to have project.app.write permission + // application ID. // // Required permissions: // - project.app.write @@ -297,8 +285,6 @@ service AppService { // // Regenerates the client secret of an API or OIDC application that belongs to the input project. // - // The user needs to have project.app.write permission - // // Required permissions: // - project.app.write rpc RegenerateClientSecret(RegenerateClientSecretRequest) returns (RegenerateClientSecretResponse) { @@ -331,8 +317,6 @@ service AppService { // The result can be sorted by app id, name, creation date, change date or state. It can also // be filtered by app state, app type and app name. // - // The user needs to have project.app.read permission - // // Required permissions: // - project.app.read rpc ListApplications(ListApplicationsRequest) returns (ListApplicationsResponse) { @@ -356,6 +340,129 @@ service AppService { } }; } + + + // Create Application Key + // + // Create a new application key, which is used to authorize an API application. + // + // Key details are returned in the response. They must be stored safely, as it will not + // be possible to retrieve them again. + // + // Required permissions: + // - `project.app.write` + rpc CreateApplicationKey(CreateApplicationKeyRequest) returns (CreateApplicationKeyResponse) { + option (google.api.http) = { + post: "/v2beta/application_keys" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The created application key"; + } + }; + }; + } + + // Delete Application Key + // + // Deletes an application key matching the provided ID. + // + // Organization ID is not mandatory, but helps with filtering/performance. + // + // The deletion time is returned in response message. + // + // Required permissions: + // - `project.app.write` + rpc DeleteApplicationKey(DeleteApplicationKeyRequest) returns (DeleteApplicationKeyResponse) { + option (google.api.http) = { + delete: "/v2beta/application_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The time of deletion."; + } + }; + }; + } + + // Get Application Key + // + // Retrieves the application key matching the provided ID. + // + // Specifying a project, organization and app ID is optional but help with filtering/performance. + // + // Required permissions: + // - project.app.read + rpc GetApplicationKey(GetApplicationKeyRequest) returns (GetApplicationKeyResponse) { + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The fetched app key."; + } + }; + }; + + option (google.api.http) = { + get: "/v2beta/application_keys/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } + + // List Application Keys + // + // Returns a list of application keys matching the input parameters. + // + // The result can be sorted by id, aggregate, creation date, expiration date, resource owner or type. + // It can also be filtered by app, project or organization ID. + // + // Required permissions: + // - project.app.read + rpc ListApplicationKeys(ListApplicationKeysRequest) returns (ListApplicationKeysResponse) { + option (google.api.http) = { + post: "/v2beta/application_keys/search" + body: "*" + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The matching applications"; + } + }; + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + } } message CreateApplicationRequest { @@ -785,4 +892,103 @@ message ListApplicationsResponse { // Contains the total number of apps matching the query and the applied limit. zitadel.filter.v2.PaginationResponse pagination = 2; -} \ No newline at end of file +} + +message CreateApplicationKeyRequest { + string app_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + + // The date the key will expire + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2519-04-01T08:45:00.000000Z\""; + description: "The date the key will expire"; + } + ]; +} + +message CreateApplicationKeyResponse { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"28746028909593987\""; + } + ]; + + // The timestamp of the app creation. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + + bytes key_details = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"eyJ0eXBlIjoiYXBwbGljYXRpb24iLCJrZXlJZCI6IjIwMjcxMDE4NjYyMjcxNDExMyIsImtleSI6Ii0tLS0tQkVHSU4gUlNBIFBSSVZBVEUgS0VZLS0tLS1cbk1JSUVvd0lCQUFLQ0FRRUFuMUxyNStTV0pGRllURU1kaXQ2U0dNY0E2Yks5dG0xMmhlcm55V0wrZm9PWnA3eEVcbk9wcmsvWE81QVplSU5NY0x0ZVhxckJlK1NPdVVNMFpLU2xCMHFTNzNjVStDVTVMTGoycVB0UzhNOFI0N3BGdFhcbjJXRTFJNjNhZHB1N01TejA2SXduQ2lyNnJYOTVPQ2ZneHA3VU1Dd0pSTUZmYXJqdjVBRXY3NXpsSS9lYUV6bUJcbkxKWU1xanZFRmZoN2x3M2lPT3VsWW9kNjNpN3RDNWl5czNlYjNLZW4yWU0rN1FSbXB2dE5qcTJMVmlIMnkrUGJcbk9ESlI3MU9ib05TYVJDNTZDUFpWVytoWDByYXI3VzMwUjI2eGtIQ09oSytQbUpSeGtOY0g1VTdja0xXMEw0WEVcbnNNZkVUSmszeDR3Q0psbisxbElXUzkrNmw0R1E2TWRzWURyOU5RSURBUUFCQW9JQkFCSkx6WGQxMHFBZEQwekNcbnNGUFFOMnJNLzVmV3hONThONDR0YWF6QXg0VHp5K050UlZDTmxScGQvYkxuR2VjbHJIeVpDSmYycWcxcHNEMHJcbkowRGRlR2d0VXBFYWxsYk9scjNEZVBsUGkrYnNsK0RKOUk2c0VSUWwxTjZtQjVzZ0ZJZllBR3UwZjlFSXdIem9cblozR25yNnBRaEVmM0JPUVdsTVhVTlJNSksyOHp3M2E1L01nRmtKVUZUSTUzeXFwbGRtZ2hLajRZR1hLRk1LUGhcbkV3RkxrRncwK2s3K0xuSjFQNGp1ZVd1RXo3WlAyaFpvUWxCcXdSajVyTG9QZ05RbUU4UytFVDRuczlUYzByOFFcbnFyaHlacDZBczJrTDhGTytCZnF3SVpDZnpnWHN2cC9PLzRaSHIzVTB2Ymp3UW1sSzdVSm42U0J6T2hpWFpNU0lcbk5Wc0V5VUVDZ1lFQTFEaktkRGo3NTM1MWQzdlRNQlRFd2JSQ3hoUVZOdENFMnMwVUw4ckJQZ1I0K1dlblNUWmFcbnprWUprcEV0bE54VGxzYnN1Y0RTUXZqeWRYYk5nSHFBeDYzMm1vdTVkak9lR0VTUDFWVGtUdElsZFZQZWszQWxcbjVYbkpQa1dqWGVyVVJZNm5KeUQ5UWhlREx3MVp4NEFYVzNHWURiTFkrT05XV0VKUlJaQUloNjBDZ1lFQXdEQ2xcbnc1MHc4dkcvbEJ4RzNSYW9FaHdLOWNna1VXOHk2T25DekNwcEtjOEZUUmY1VE5iWjl5TzNXUmdYajhkeHRCakFcbkl5VGlzYk9NQk1VaFZKUUtGZHRQaDhoVDBwRkRjeE9ndzY0aHBtYzhyY2RTbXVKNzlYSVRTaHUySjA0N0UvNFZcbnJOTThpWVk5ZGR3VGdGUUlsdFNZL0l0RnFxWERmdjhqK1dVY25La0NnWUVBaENOUU80bDNuNjRucWR2WnBTaHBcblVrclJBTkJrWFJyOGZkZ1BaNnFSSS9KWStNSEhjVmg4dGM3NkN0NkdTUmZlbkJVRU5LeVF2czZPK1FDZCtBOU9cbnZBWGZkRjduZldlcVdtWG1RT2g0dDNNMWk1WkxFZlpVUWt2UU9BdllLcFFhMDZ4OCsyb1pCdHZvL0pVTmY2Q0xcbjZvNFNKUVZrLzZOZGtkckpDODBnNG9rQ2dZQkZsNWYrbkVYa1F0dWZVeG5wNXRGWE5XWldsM0ZuTjMvVXpRaW5cbmkxZm5OcnB4cnhPcjJrUzA4KzdwU1FzSEdpNDNDNXRQWG9UajJlTUN1eXNWaUVHYXBuNUc2YWhJb0NjdlhWVWlcblprUnpFQUR0NERZdU5ZS3pYdXBUTkhPaUNmYmtoMlhyM2RXVzZ0QUloSGRmU1k2T3AwNzZhNmYvWWVUSGNMWGpcbkVkVHBlUUtCZ0FPdnBqcDQ4TzRUWEZkU0JLSnYya005OHVhUjlSQURtdGxTWHd2cTlyQkhTV084NFk4bzE0L1Bcbkl1UmxUOHhROGRYKzhMR21UUCtjcUtiOFFRQ1grQk1YUWxMSEVtWnpnb0xFa0pGMUVIMm4vZEZ5bngxS3prdFNcbm9UZUdsRzZhbXhVOVh4eW9RVFlEVGJCbERwc2FZUlFBZ2FUQzM3UVZRUjhmK1ZoRzFHSFFcbi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tXG4iLCJhcHBJZCI6IjIwMjcwNjM5ODgxMzg4MDU3NyIsImNsaWVudElkIjoiMjAyNzA2Mzk4ODEzOTQ2MTEzQG15dGVzdHByb2plY3QifQ==\""; + } + ]; +} + +message DeleteApplicationKeyRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string project_id = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string application_id = 3 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string organization_id = 4 [(validate.rules).string = {max_len: 200}]; +} + +message DeleteApplicationKeyResponse { + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message GetApplicationKeyRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string project_id = 2 [(validate.rules).string = {max_len: 200}]; + string application_id = 3 [(validate.rules).string = {max_len: 200}]; + string organization_id = 4 [(validate.rules).string = {max_len: 200}]; +} + +message GetApplicationKeyResponse { + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629023906488334\""; + } + ]; + + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + + // the date a key will expire + google.protobuf.Timestamp expiration_date = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the date a key will expire"; + example: "\"3019-04-01T08:45:00.000000Z\""; + } + ]; +} + +message ListApplicationKeysRequest { + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 1; + + ApplicationKeysSorting sorting_column = 2; + + oneof resource_id { + string application_id = 3 [(validate.rules).string = {min_len: 1; max_len: 200}]; + string project_id = 4 [(validate.rules).string = {min_len: 1; max_len: 200}]; + string organization_id = 5 [(validate.rules).string = {min_len: 1; max_len: 200}]; + } +} + +message ListApplicationKeysResponse { + repeated ApplicationKey keys = 1; + + // Contains the total number of app keys matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 74d5dcf60b..bb62e2eba6 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -3709,6 +3709,7 @@ service ManagementService { }; } + // Deprecated: Use [GetApplicationKey](/apis/resources/application_service_v2/application-service-get-application-key.api.mdx) instead to get an application key rpc GetAppKey(GetAppKeyRequest) returns (GetAppKeyResponse) { option (google.api.http) = { get: "/projects/{project_id}/apps/{app_id}/keys/{key_id}" @@ -3731,9 +3732,11 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [ListApplicationKeys](/apis/resources/application_service_v2/application-service-list-application-keys.api.mdx) instead to list application keys rpc ListAppKeys(ListAppKeysRequest) returns (ListAppKeysResponse) { option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/keys/_search" @@ -3760,6 +3763,8 @@ service ManagementService { }; } + // Deprecated: Use [CreateApplicationKey](/apis/resources/application_service_v2/application-service-create-application-key.api.mdx) instead to + // create an application key rpc AddAppKey(AddAppKeyRequest) returns (AddAppKeyResponse){ option (google.api.http) = { post: "/projects/{project_id}/apps/{app_id}/keys" @@ -3783,9 +3788,12 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } + // Deprecated: Use [DeleteApplicationKey](/apis/resources/application_service_v2/application-service-delete-application-key.api.mdx) instead to + // delete an application key rpc RemoveAppKey(RemoveAppKeyRequest) returns (RemoveAppKeyResponse) { option (google.api.http) = { delete: "/projects/{project_id}/apps/{app_id}/keys/{key_id}" @@ -3808,6 +3816,7 @@ service ManagementService { required: false; }; }; + deprecated: true; }; } From 2928c6ac2bba8799f002fca0548f165f8cfe1be0 Mon Sep 17 00:00:00 2001 From: Elio Bischof Date: Wed, 2 Jul 2025 10:04:19 +0200 Subject: [PATCH 113/123] chore(login): migrate nextjs login to monorepo (#10134) # Which Problems Are Solved We move the login code to the zitadel repo. # How the Problems Are Solved The login repo is added to ./login as a git subtree pulled from the dockerize-ci branch. Apart from the login code, this PR contains the changes from #10116 # Additional Context - Closes https://github.com/zitadel/typescript/issues/474 - Also merges #10116 - Merging is blocked by failing check because of: - https://github.com/zitadel/zitadel/pull/10134#issuecomment-3012086106 --------- Co-authored-by: Max Peintner Co-authored-by: Max Peintner Co-authored-by: Florian Forster --- .github/dependabot.yml | 13 + .github/workflows/build.yml | 28 +- .github/workflows/compile.yml | 36 +- .github/workflows/login-container.yml | 63 + .github/workflows/login-quality.yml | 59 + .github/workflows/release.yml | 70 + .golangci.yaml | 1 + LICENSING.md | 7 + Makefile | 50 +- build/Dockerfile.gitignore | 3 + cmd/defaults.yaml | 10 +- docker-bake.hcl | 5 + dockerfiles/proto-files.Dockerfile | 8 + .../proto-files.Dockerfile.dockerignore | 2 + .../typescript-proto-client.Dockerfile | 8 + ...cript-proto-client.Dockerfile.dockerignore | 11 + e2e/config/host.docker.internal/zitadel.yaml | 3 + e2e/config/localhost/zitadel.yaml | 3 + internal/integration/config/zitadel.yaml | 7 + login/.changeset/README.md | 8 + login/.changeset/config.json | 11 + login/.eslintrc.cjs | 10 + login/.github/ISSUE_TEMPLATE/bug.yaml | 63 + login/.github/ISSUE_TEMPLATE/config.yml | 4 + login/.github/ISSUE_TEMPLATE/docs.yaml | 30 + login/.github/ISSUE_TEMPLATE/improvement.yaml | 54 + login/.github/ISSUE_TEMPLATE/proposal.yaml | 54 + login/.github/custom-i18n.png | Bin 0 -> 85028 bytes login/.github/dependabot.yml | 21 + login/.github/pull_request_template.md | 13 + login/.github/workflows/close_pr.yml | 35 + login/.github/workflows/issues.yml | 41 + login/.github/workflows/release.yml | 32 + login/.github/workflows/test.yml | 67 + login/.gitignore | 18 + login/.npmrc | 1 + login/.nvmrc | 1 + login/.prettierignore | 9 + login/.prettierrc | 6 + login/CODE_OF_CONDUCT.md | 128 + login/CONTRIBUTING.md | 206 + login/LICENSE | 21 + login/Makefile | 137 + login/README.md | 264 + login/acceptance/docker-compose.yaml | 71 + login/apps/login-test-acceptance/.gitignore | 1 + .../docker-compose-ci.yaml | 59 + .../login-test-acceptance/docker-compose.yaml | 237 + .../go-command.Dockerfile | 11 + .../login-test-acceptance/idp/oidc/go.mod | 28 + .../login-test-acceptance/idp/oidc/go.sum | 71 + .../login-test-acceptance/idp/oidc/main.go | 186 + .../login-test-acceptance/idp/saml/go.mod | 16 + .../login-test-acceptance/idp/saml/go.sum | 49 + .../login-test-acceptance/idp/saml/main.go | 328 + .../apps/login-test-acceptance/oidcrp/go.mod | 26 + .../apps/login-test-acceptance/oidcrp/go.sum | 67 + .../apps/login-test-acceptance/oidcrp/main.go | 322 + login/apps/login-test-acceptance/package.json | 18 + .../apps/login-test-acceptance/pat/.gitignore | 2 + login/apps/login-test-acceptance/pat/.gitkeep | 0 .../playwright-report/.gitignore | 2 + .../playwright-report/.gitkeep | 0 .../playwright.config.ts | 78 + .../apps/login-test-acceptance/samlsp/go.mod | 18 + .../apps/login-test-acceptance/samlsp/go.sum | 38 + .../apps/login-test-acceptance/samlsp/main.go | 271 + login/apps/login-test-acceptance/setup/go.mod | 3 + login/apps/login-test-acceptance/setup/go.sum | 0 .../apps/login-test-acceptance/setup/main.go | 3 + .../apps/login-test-acceptance/setup/setup.sh | 139 + login/apps/login-test-acceptance/sink/go.mod | 3 + login/apps/login-test-acceptance/sink/go.sum | 0 login/apps/login-test-acceptance/sink/main.go | 111 + .../test-results/.gitignore | 2 + .../test-results/.gitkeep | 0 .../login-test-acceptance/tests/admin.spec.ts | 7 + .../tests/code-screen.ts | 12 + .../apps/login-test-acceptance/tests/code.ts | 17 + .../tests/email-verify-screen.ts | 12 + .../tests/email-verify.spec.ts | 69 + .../tests/email-verify.ts | 15 + .../tests/idp-apple.spec.ts | 102 + .../tests/idp-generic-jwt.spec.ts | 99 + .../tests/idp-generic-oauth.spec.ts | 99 + .../tests/idp-generic-oidc.spec.ts | 101 + .../tests/idp-github-enterprise.spec.ts | 103 + .../tests/idp-github.spec.ts | 103 + .../tests/idp-gitlab-self-hosted.spec.ts | 103 + .../tests/idp-gitlab.spec.ts | 103 + .../tests/idp-google.spec.ts | 99 + .../tests/idp-ldap.spec.ts | 99 + .../tests/idp-microsoft.spec.ts | 102 + .../tests/idp-saml.spec.ts | 103 + .../login-configuration-possiblities.spec.ts | 57 + .../apps/login-test-acceptance/tests/login.ts | 41 + .../tests/loginname-screen.ts | 12 + .../login-test-acceptance/tests/loginname.ts | 7 + .../login-test-acceptance/tests/passkey.ts | 109 + .../tests/password-screen.ts | 98 + .../login-test-acceptance/tests/password.ts | 29 + .../tests/register-screen.ts | 27 + .../tests/register.spec.ts | 183 + .../login-test-acceptance/tests/register.ts | 39 + .../tests/select-account.ts | 5 + .../apps/login-test-acceptance/tests/sink.ts | 43 + .../apps/login-test-acceptance/tests/user.ts | 177 + .../tests/username-passkey.spec.ts | 43 + .../username-password-change-required.spec.ts | 41 + .../tests/username-password-changed.spec.ts | 54 + .../tests/username-password-otp_email.spec.ts | 98 + .../tests/username-password-otp_sms.spec.ts | 71 + .../tests/username-password-set.spec.ts | 52 + .../tests/username-password-totp.spec.ts | 71 + .../tests/username-password-u2f.spec.ts | 26 + .../tests/username-password.spec.ts | 157 + .../login-test-acceptance/tests/welcome.ts | 6 + .../login-test-acceptance/tests/zitadel.ts | 190 + login/apps/login-test-acceptance/turbo.json | 10 + login/apps/login-test-acceptance/zitadel.yaml | 83 + login/apps/login-test-integration/.gitignore | 2 + .../core-mock/Dockerfile | 9 + .../zitadel.settings.v2.SettingsService.json | 59 + .../core-mock/mocked-services.cfg | 7 + .../login-test-integration/cypress.config.ts | 14 + .../docker-compose.yaml | 30 + .../fixtures/example.json | 5 + .../integration/invite.cy.ts | 110 + .../integration/login.cy.ts | 172 + .../integration/register-idp.cy.ts | 21 + .../integration/register.cy.ts | 73 + .../integration/verify.cy.ts | 95 + .../apps/login-test-integration/package.json | 17 + .../login-test-integration/support/e2e.ts | 29 + .../apps/login-test-integration/tsconfig.json | 8 + login/apps/login-test-integration/turbo.json | 10 + login/apps/login/.env.test | 5 + login/apps/login/.eslintrc.cjs | 12 + login/apps/login/.gitignore | 3 + login/apps/login/.prettierignore | 2 + login/apps/login/constants/csp.js | 2 + login/apps/login/locales/de.json | 250 + login/apps/login/locales/en.json | 250 + login/apps/login/locales/es.json | 250 + login/apps/login/locales/it.json | 250 + login/apps/login/locales/pl.json | 250 + login/apps/login/locales/ru.json | 250 + login/apps/login/locales/zh.json | 250 + login/apps/login/next-env-vars.d.ts | 33 + login/apps/login/next-env.d.ts | 5 + login/apps/login/next.config.mjs | 83 + login/apps/login/package.json | 75 + login/apps/login/postcss.config.cjs | 6 + login/apps/login/prettier.config.mjs | 1 + login/apps/login/public/checkbox.svg | 1 + login/apps/login/public/favicon.ico | Bin 0 -> 15086 bytes .../public/favicon/android-chrome-192x192.png | Bin 0 -> 17828 bytes .../public/favicon/android-chrome-512x512.png | Bin 0 -> 137768 bytes .../login/public/favicon/apple-touch-icon.png | Bin 0 -> 18112 bytes .../login/public/favicon/browserconfig.xml | 9 + .../login/public/favicon/favicon-16x16.png | Bin 0 -> 1551 bytes .../login/public/favicon/favicon-32x32.png | Bin 0 -> 2050 bytes login/apps/login/public/favicon/favicon.ico | Bin 0 -> 15086 bytes .../login/public/favicon/mstile-150x150.png | Bin 0 -> 13206 bytes .../login/public/favicon/site.webmanifest | 19 + login/apps/login/public/grid-dark.svg | 5 + login/apps/login/public/grid-light.svg | 5 + .../logo/zitadel-logo-solo-darkdesign.svg | 74 + .../logo/zitadel-logo-solo-lightdesign.svg | 76 + login/apps/login/public/zitadel-logo-dark.svg | 101 + .../apps/login/public/zitadel-logo-light.svg | 99 + login/apps/login/readme.md | 394 + login/apps/login/screenshots/accounts.png | Bin 0 -> 159830 bytes .../login/screenshots/accounts_jumpto.png | Bin 0 -> 15180 bytes login/apps/login/screenshots/collage.png | Bin 0 -> 288519 bytes login/apps/login/screenshots/idp.png | Bin 0 -> 86616 bytes login/apps/login/screenshots/loginname.png | Bin 0 -> 114853 bytes login/apps/login/screenshots/mfa.png | Bin 0 -> 104053 bytes login/apps/login/screenshots/mfaset.png | Bin 0 -> 116794 bytes login/apps/login/screenshots/otp.png | Bin 0 -> 84122 bytes login/apps/login/screenshots/otpset.png | Bin 0 -> 146885 bytes login/apps/login/screenshots/passkey.png | Bin 0 -> 86883 bytes login/apps/login/screenshots/password.png | Bin 0 -> 84874 bytes .../login/screenshots/password_change.png | Bin 0 -> 123203 bytes login/apps/login/screenshots/password_set.png | Bin 0 -> 153578 bytes login/apps/login/screenshots/register.png | Bin 0 -> 161800 bytes .../login/screenshots/register_password.png | Bin 0 -> 118094 bytes login/apps/login/screenshots/signedin.png | Bin 0 -> 60794 bytes login/apps/login/screenshots/u2f.png | Bin 0 -> 76779 bytes login/apps/login/screenshots/u2fset.png | Bin 0 -> 90769 bytes login/apps/login/screenshots/verify.png | Bin 0 -> 67934 bytes .../login/src/app/(login)/accounts/page.tsx | 97 + .../app/(login)/authenticator/set/page.tsx | 218 + .../src/app/(login)/device/consent/page.tsx | 99 + .../login/src/app/(login)/device/page.tsx | 48 + login/apps/login/src/app/(login)/error.tsx | 27 + .../(login)/idp/[provider]/failure/page.tsx | 105 + .../(login)/idp/[provider]/success/page.tsx | 340 + .../login/src/app/(login)/idp/ldap/page.tsx | 56 + login/apps/login/src/app/(login)/idp/page.tsx | 51 + login/apps/login/src/app/(login)/layout.tsx | 62 + .../login/src/app/(login)/loginname/page.tsx | 93 + .../login/src/app/(login)/logout/page.tsx | 86 + .../src/app/(login)/logout/success/page.tsx | 43 + login/apps/login/src/app/(login)/mfa/page.tsx | 134 + .../login/src/app/(login)/mfa/set/page.tsx | 174 + .../src/app/(login)/otp/[method]/page.tsx | 136 + .../src/app/(login)/otp/[method]/set/page.tsx | 204 + login/apps/login/src/app/(login)/page.tsx | 8 + .../login/src/app/(login)/passkey/page.tsx | 89 + .../src/app/(login)/passkey/set/page.tsx | 85 + .../src/app/(login)/password/change/page.tsx | 100 + .../login/src/app/(login)/password/page.tsx | 102 + .../src/app/(login)/password/set/page.tsx | 137 + .../login/src/app/(login)/register/page.tsx | 136 + .../app/(login)/register/password/page.tsx | 100 + .../login/src/app/(login)/saml-post/route.ts | 30 + .../login/src/app/(login)/signedin/page.tsx | 141 + login/apps/login/src/app/(login)/u2f/page.tsx | 96 + .../login/src/app/(login)/u2f/set/page.tsx | 76 + .../login/src/app/(login)/verify/page.tsx | 174 + .../src/app/(login)/verify/success/page.tsx | 92 + login/apps/login/src/app/global-error.tsx | 36 + login/apps/login/src/app/healthy/route.ts | 5 + login/apps/login/src/app/login/route.ts | 557 + login/apps/login/src/app/security/route.ts | 28 + .../apps/login/src/components/address-bar.tsx | 61 + login/apps/login/src/components/alert.tsx | 45 + .../apps/login/src/components/app-avatar.tsx | 48 + .../login/src/components/auth-methods.tsx | 234 + .../authentication-method-radio.tsx | 104 + login/apps/login/src/components/avatar.tsx | 97 + .../apps/login/src/components/back-button.tsx | 18 + login/apps/login/src/components/boundary.tsx | 83 + login/apps/login/src/components/button.tsx | 74 + .../src/components/change-password-form.tsx | 211 + login/apps/login/src/components/checkbox.tsx | 62 + .../choose-authenticator-to-login.tsx | 38 + .../choose-authenticator-to-setup.tsx | 51 + .../choose-second-factor-to-setup.tsx | 119 + .../src/components/choose-second-factor.tsx | 54 + login/apps/login/src/components/consent.tsx | 116 + .../src/components/copy-to-clipboard.tsx | 41 + .../login/src/components/default-tags.tsx | 32 + .../login/src/components/device-code-form.tsx | 95 + .../login/src/components/dynamic-theme.tsx | 43 + .../login/src/components/external-link.tsx | 21 + .../apps/login/src/components/idp-signin.tsx | 67 + .../login/src/components/idps/base-button.tsx | 41 + .../components/idps/pages/complete-idp.tsx | 55 + .../components/idps/pages/linking-failed.tsx | 27 + .../components/idps/pages/linking-success.tsx | 30 + .../components/idps/pages/login-failed.tsx | 24 + .../components/idps/pages/login-success.tsx | 30 + .../components/idps/sign-in-with-apple.tsx | 36 + .../components/idps/sign-in-with-azure-ad.tsx | 42 + .../components/idps/sign-in-with-generic.tsx | 21 + .../components/idps/sign-in-with-github.tsx | 64 + .../idps/sign-in-with-gitlab.test.tsx | 45 + .../components/idps/sign-in-with-gitlab.tsx | 53 + .../idps/sign-in-with-google.test.tsx | 44 + .../components/idps/sign-in-with-google.tsx | 66 + login/apps/login/src/components/input.tsx | 102 + .../src/components/language-provider.tsx | 13 + .../src/components/language-switcher.tsx | 74 + .../login/src/components/layout-providers.tsx | 17 + .../ldap-username-password-form.tsx | 109 + login/apps/login/src/components/login-otp.tsx | 284 + .../login/src/components/login-passkey.tsx | 280 + login/apps/login/src/components/logo.tsx | 37 + .../components/password-complexity.test.tsx | 64 + .../src/components/password-complexity.tsx | 99 + .../login/src/components/password-form.tsx | 176 + .../components/privacy-policy-checkboxes.tsx | 105 + .../register-form-idp-incomplete.tsx | 156 + .../login/src/components/register-form.tsx | 227 + .../login/src/components/register-passkey.tsx | 220 + .../login/src/components/register-u2f.tsx | 225 + .../src/components/self-service-menu.tsx | 42 + .../src/components/session-clear-item.tsx | 105 + .../login/src/components/session-item.tsx | 156 + .../src/components/sessions-clear-list.tsx | 109 + .../login/src/components/sessions-list.tsx | 50 + .../src/components/set-password-form.tsx | 286 + .../components/set-register-password-form.tsx | 170 + .../login/src/components/sign-in-with-idp.tsx | 93 + .../login/src/components/skeleton-card.tsx | 16 + login/apps/login/src/components/skeleton.tsx | 9 + login/apps/login/src/components/spinner.tsx | 22 + .../apps/login/src/components/state-badge.tsx | 40 + login/apps/login/src/components/tab-group.tsx | 16 + login/apps/login/src/components/tab.tsx | 35 + .../login/src/components/theme-provider.tsx | 16 + .../login/src/components/theme-wrapper.tsx | 18 + login/apps/login/src/components/theme.tsx | 44 + .../login/src/components/totp-register.tsx | 157 + .../apps/login/src/components/translated.tsx | 23 + .../apps/login/src/components/user-avatar.tsx | 59 + .../login/src/components/username-form.tsx | 156 + .../apps/login/src/components/verify-form.tsx | 168 + .../src/components/zitadel-logo-dark.tsx | 210 + .../src/components/zitadel-logo-light.tsx | 210 + .../login/src/components/zitadel-logo.tsx | 32 + login/apps/login/src/helpers/base64.ts | 63 + login/apps/login/src/helpers/colors.ts | 439 + login/apps/login/src/helpers/validators.ts | 19 + login/apps/login/src/i18n/request.ts | 59 + login/apps/login/src/lib/api.ts | 17 + login/apps/login/src/lib/client.ts | 80 + login/apps/login/src/lib/cookies.ts | 341 + login/apps/login/src/lib/demos.ts | 38 + login/apps/login/src/lib/fingerprint.ts | 66 + login/apps/login/src/lib/hooks.ts | 14 + login/apps/login/src/lib/i18n.ts | 38 + login/apps/login/src/lib/idp.ts | 77 + login/apps/login/src/lib/oidc.ts | 132 + login/apps/login/src/lib/saml.ts | 130 + login/apps/login/src/lib/self.ts | 60 + login/apps/login/src/lib/server/cookie.ts | 278 + login/apps/login/src/lib/server/device.ts | 20 + login/apps/login/src/lib/server/idp.ts | 241 + login/apps/login/src/lib/server/loginname.ts | 454 + login/apps/login/src/lib/server/oidc.ts | 15 + login/apps/login/src/lib/server/otp.ts | 83 + login/apps/login/src/lib/server/passkeys.ts | 278 + login/apps/login/src/lib/server/password.ts | 460 + login/apps/login/src/lib/server/register.ts | 233 + login/apps/login/src/lib/server/session.ts | 221 + login/apps/login/src/lib/server/u2f.ts | 103 + login/apps/login/src/lib/server/verify.ts | 329 + login/apps/login/src/lib/service-url.ts | 58 + login/apps/login/src/lib/service.ts | 49 + login/apps/login/src/lib/session.ts | 194 + login/apps/login/src/lib/verify-helper.ts | 289 + login/apps/login/src/lib/zitadel.ts | 1525 +++ login/apps/login/src/middleware.ts | 109 + login/apps/login/src/styles/globals.scss | 65 + login/apps/login/src/styles/vars.scss | 174 + login/apps/login/tailwind.config.mjs | 117 + login/apps/login/tsconfig.json | 24 + login/apps/login/turbo.json | 22 + login/apps/login/vitest.config.mts | 12 + login/docker-bake.hcl | 145 + login/dockerfiles/login-client.Dockerfile | 7 + .../login-client.Dockerfile.dockerignore | 11 + login/dockerfiles/login-dev-base.Dockerfile | 3 + .../login-dev-base.Dockerfile.dockerignore | 1 + login/dockerfiles/login-lint.Dockerfile | 7 + .../login-lint.Dockerfile.dockerignore | 25 + login/dockerfiles/login-pnpm.Dockerfile | 10 + .../login-pnpm.Dockerfile.dockerignore | 6 + login/dockerfiles/login-standalone.Dockerfile | 34 + .../login-standalone.Dockerfile.dockerignore | 17 + .../login-test-acceptance.Dockerfile | 8 + ...in-test-acceptance.Dockerfile.dockerignore | 5 + .../login-test-integration.Dockerfile | 11 + ...n-test-integration.Dockerfile.dockerignore | 9 + login/dockerfiles/login-test-unit.Dockerfile | 6 + .../login-test-unit.Dockerfile.dockerignore | 13 + ...gin-typescript-proto-client-out.Dockerfile | 5 + ...t-proto-client-out.Dockerfile.dockerignore | 1 + login/dockerfiles/proto-files.Dockerfile | 8 + .../proto-files.Dockerfile.dockerignore | 1 + .../typescript-proto-client.Dockerfile | 6 + ...cript-proto-client.Dockerfile.dockerignore | 11 + login/meta.json | 4 + login/package.json | 55 + login/packages/zitadel-client/.eslintrc.cjs | 4 + login/packages/zitadel-client/.gitignore | 4 + login/packages/zitadel-client/CHANGELOG.md | 77 + login/packages/zitadel-client/README.md | 53 + login/packages/zitadel-client/package.json | 71 + login/packages/zitadel-client/src/helpers.ts | 11 + login/packages/zitadel-client/src/index.ts | 10 + .../zitadel-client/src/interceptors.test.ts | 67 + .../zitadel-client/src/interceptors.ts | 16 + login/packages/zitadel-client/src/node.ts | 36 + login/packages/zitadel-client/src/v1.ts | 11 + login/packages/zitadel-client/src/v2.ts | 27 + login/packages/zitadel-client/src/v3alpha.ts | 6 + login/packages/zitadel-client/src/web.ts | 15 + login/packages/zitadel-client/tsconfig.json | 5 + login/packages/zitadel-client/tsup.config.ts | 13 + login/packages/zitadel-client/turbo.json | 12 + .../zitadel-eslint-config/CHANGELOG.md | 13 + .../packages/zitadel-eslint-config/README.md | 35 + login/packages/zitadel-eslint-config/index.js | 13 + .../zitadel-eslint-config/package.json | 17 + .../zitadel-prettier-config/CHANGELOG.md | 13 + .../zitadel-prettier-config/README.md | 36 + .../packages/zitadel-prettier-config/index.js | 11 + .../zitadel-prettier-config/package.json | 12 + login/packages/zitadel-proto/.gitignore | 5 + login/packages/zitadel-proto/CHANGELOG.md | 47 + login/packages/zitadel-proto/README.md | 35 + login/packages/zitadel-proto/buf.gen.yaml | 10 + login/packages/zitadel-proto/package.json | 26 + login/packages/zitadel-proto/turbo.json | 9 + .../zitadel-tailwind-config/CHANGELOG.md | 13 + .../zitadel-tailwind-config/README.md | 36 + .../zitadel-tailwind-config/package.json | 12 + .../tailwind.config.mjs | 97 + login/packages/zitadel-tsconfig/CHANGELOG.md | 13 + login/packages/zitadel-tsconfig/README.md | 35 + login/packages/zitadel-tsconfig/base.json | 20 + login/packages/zitadel-tsconfig/nextjs.json | 32 + login/packages/zitadel-tsconfig/node20.json | 10 + login/packages/zitadel-tsconfig/package.json | 9 + .../zitadel-tsconfig/react-library.json | 11 + login/packages/zitadel-tsconfig/tsup.json | 5 + login/pnpm-lock.yaml | 9519 +++++++++++++++++ login/pnpm-workspace.yaml | 3 + login/scripts/entrypoint.sh | 11 + login/scripts/healthcheck.js | 14 + login/scripts/run_or_skip.sh | 67 + login/turbo.json | 51 + 416 files changed, 38969 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/login-container.yml create mode 100644 .github/workflows/login-quality.yml create mode 100644 build/Dockerfile.gitignore create mode 100644 docker-bake.hcl create mode 100644 dockerfiles/proto-files.Dockerfile create mode 100644 dockerfiles/proto-files.Dockerfile.dockerignore create mode 100644 dockerfiles/typescript-proto-client.Dockerfile create mode 100644 dockerfiles/typescript-proto-client.Dockerfile.dockerignore create mode 100644 login/.changeset/README.md create mode 100644 login/.changeset/config.json create mode 100644 login/.eslintrc.cjs create mode 100644 login/.github/ISSUE_TEMPLATE/bug.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/config.yml create mode 100644 login/.github/ISSUE_TEMPLATE/docs.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/improvement.yaml create mode 100644 login/.github/ISSUE_TEMPLATE/proposal.yaml create mode 100644 login/.github/custom-i18n.png create mode 100644 login/.github/dependabot.yml create mode 100644 login/.github/pull_request_template.md create mode 100644 login/.github/workflows/close_pr.yml create mode 100644 login/.github/workflows/issues.yml create mode 100644 login/.github/workflows/release.yml create mode 100644 login/.github/workflows/test.yml create mode 100644 login/.gitignore create mode 100644 login/.npmrc create mode 100644 login/.nvmrc create mode 100644 login/.prettierignore create mode 100644 login/.prettierrc create mode 100644 login/CODE_OF_CONDUCT.md create mode 100644 login/CONTRIBUTING.md create mode 100644 login/LICENSE create mode 100644 login/Makefile create mode 100644 login/README.md create mode 100644 login/acceptance/docker-compose.yaml create mode 100644 login/apps/login-test-acceptance/.gitignore create mode 100644 login/apps/login-test-acceptance/docker-compose-ci.yaml create mode 100644 login/apps/login-test-acceptance/docker-compose.yaml create mode 100644 login/apps/login-test-acceptance/go-command.Dockerfile create mode 100644 login/apps/login-test-acceptance/idp/oidc/go.mod create mode 100644 login/apps/login-test-acceptance/idp/oidc/go.sum create mode 100644 login/apps/login-test-acceptance/idp/oidc/main.go create mode 100644 login/apps/login-test-acceptance/idp/saml/go.mod create mode 100644 login/apps/login-test-acceptance/idp/saml/go.sum create mode 100644 login/apps/login-test-acceptance/idp/saml/main.go create mode 100644 login/apps/login-test-acceptance/oidcrp/go.mod create mode 100644 login/apps/login-test-acceptance/oidcrp/go.sum create mode 100644 login/apps/login-test-acceptance/oidcrp/main.go create mode 100644 login/apps/login-test-acceptance/package.json create mode 100644 login/apps/login-test-acceptance/pat/.gitignore create mode 100644 login/apps/login-test-acceptance/pat/.gitkeep create mode 100644 login/apps/login-test-acceptance/playwright-report/.gitignore create mode 100644 login/apps/login-test-acceptance/playwright-report/.gitkeep create mode 100644 login/apps/login-test-acceptance/playwright.config.ts create mode 100644 login/apps/login-test-acceptance/samlsp/go.mod create mode 100644 login/apps/login-test-acceptance/samlsp/go.sum create mode 100644 login/apps/login-test-acceptance/samlsp/main.go create mode 100644 login/apps/login-test-acceptance/setup/go.mod create mode 100644 login/apps/login-test-acceptance/setup/go.sum create mode 100644 login/apps/login-test-acceptance/setup/main.go create mode 100755 login/apps/login-test-acceptance/setup/setup.sh create mode 100644 login/apps/login-test-acceptance/sink/go.mod create mode 100644 login/apps/login-test-acceptance/sink/go.sum create mode 100644 login/apps/login-test-acceptance/sink/main.go create mode 100644 login/apps/login-test-acceptance/test-results/.gitignore create mode 100644 login/apps/login-test-acceptance/test-results/.gitkeep create mode 100644 login/apps/login-test-acceptance/tests/admin.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/code-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/code.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/email-verify.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-apple.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-github.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-google.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-ldap.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/idp-saml.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/login.ts create mode 100644 login/apps/login-test-acceptance/tests/loginname-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/loginname.ts create mode 100644 login/apps/login-test-acceptance/tests/passkey.ts create mode 100644 login/apps/login-test-acceptance/tests/password-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/password.ts create mode 100644 login/apps/login-test-acceptance/tests/register-screen.ts create mode 100644 login/apps/login-test-acceptance/tests/register.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/register.ts create mode 100644 login/apps/login-test-acceptance/tests/select-account.ts create mode 100644 login/apps/login-test-acceptance/tests/sink.ts create mode 100644 login/apps/login-test-acceptance/tests/user.ts create mode 100644 login/apps/login-test-acceptance/tests/username-passkey.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-changed.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-set.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-totp.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/username-password.spec.ts create mode 100644 login/apps/login-test-acceptance/tests/welcome.ts create mode 100644 login/apps/login-test-acceptance/tests/zitadel.ts create mode 100644 login/apps/login-test-acceptance/turbo.json create mode 100644 login/apps/login-test-acceptance/zitadel.yaml create mode 100644 login/apps/login-test-integration/.gitignore create mode 100644 login/apps/login-test-integration/core-mock/Dockerfile create mode 100644 login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json create mode 100644 login/apps/login-test-integration/core-mock/mocked-services.cfg create mode 100644 login/apps/login-test-integration/cypress.config.ts create mode 100644 login/apps/login-test-integration/docker-compose.yaml create mode 100644 login/apps/login-test-integration/fixtures/example.json create mode 100644 login/apps/login-test-integration/integration/invite.cy.ts create mode 100644 login/apps/login-test-integration/integration/login.cy.ts create mode 100644 login/apps/login-test-integration/integration/register-idp.cy.ts create mode 100644 login/apps/login-test-integration/integration/register.cy.ts create mode 100644 login/apps/login-test-integration/integration/verify.cy.ts create mode 100644 login/apps/login-test-integration/package.json create mode 100644 login/apps/login-test-integration/support/e2e.ts create mode 100644 login/apps/login-test-integration/tsconfig.json create mode 100644 login/apps/login-test-integration/turbo.json create mode 100644 login/apps/login/.env.test create mode 100755 login/apps/login/.eslintrc.cjs create mode 100644 login/apps/login/.gitignore create mode 100644 login/apps/login/.prettierignore create mode 100644 login/apps/login/constants/csp.js create mode 100644 login/apps/login/locales/de.json create mode 100644 login/apps/login/locales/en.json create mode 100644 login/apps/login/locales/es.json create mode 100644 login/apps/login/locales/it.json create mode 100644 login/apps/login/locales/pl.json create mode 100644 login/apps/login/locales/ru.json create mode 100644 login/apps/login/locales/zh.json create mode 100644 login/apps/login/next-env-vars.d.ts create mode 100755 login/apps/login/next-env.d.ts create mode 100755 login/apps/login/next.config.mjs create mode 100644 login/apps/login/package.json create mode 100644 login/apps/login/postcss.config.cjs create mode 100644 login/apps/login/prettier.config.mjs create mode 100644 login/apps/login/public/checkbox.svg create mode 100644 login/apps/login/public/favicon.ico create mode 100644 login/apps/login/public/favicon/android-chrome-192x192.png create mode 100644 login/apps/login/public/favicon/android-chrome-512x512.png create mode 100644 login/apps/login/public/favicon/apple-touch-icon.png create mode 100644 login/apps/login/public/favicon/browserconfig.xml create mode 100644 login/apps/login/public/favicon/favicon-16x16.png create mode 100644 login/apps/login/public/favicon/favicon-32x32.png create mode 100644 login/apps/login/public/favicon/favicon.ico create mode 100644 login/apps/login/public/favicon/mstile-150x150.png create mode 100644 login/apps/login/public/favicon/site.webmanifest create mode 100644 login/apps/login/public/grid-dark.svg create mode 100644 login/apps/login/public/grid-light.svg create mode 100644 login/apps/login/public/logo/zitadel-logo-solo-darkdesign.svg create mode 100644 login/apps/login/public/logo/zitadel-logo-solo-lightdesign.svg create mode 100644 login/apps/login/public/zitadel-logo-dark.svg create mode 100644 login/apps/login/public/zitadel-logo-light.svg create mode 100644 login/apps/login/readme.md create mode 100644 login/apps/login/screenshots/accounts.png create mode 100644 login/apps/login/screenshots/accounts_jumpto.png create mode 100644 login/apps/login/screenshots/collage.png create mode 100644 login/apps/login/screenshots/idp.png create mode 100644 login/apps/login/screenshots/loginname.png create mode 100644 login/apps/login/screenshots/mfa.png create mode 100644 login/apps/login/screenshots/mfaset.png create mode 100644 login/apps/login/screenshots/otp.png create mode 100644 login/apps/login/screenshots/otpset.png create mode 100644 login/apps/login/screenshots/passkey.png create mode 100644 login/apps/login/screenshots/password.png create mode 100644 login/apps/login/screenshots/password_change.png create mode 100644 login/apps/login/screenshots/password_set.png create mode 100644 login/apps/login/screenshots/register.png create mode 100644 login/apps/login/screenshots/register_password.png create mode 100644 login/apps/login/screenshots/signedin.png create mode 100644 login/apps/login/screenshots/u2f.png create mode 100644 login/apps/login/screenshots/u2fset.png create mode 100644 login/apps/login/screenshots/verify.png create mode 100644 login/apps/login/src/app/(login)/accounts/page.tsx create mode 100644 login/apps/login/src/app/(login)/authenticator/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/device/consent/page.tsx create mode 100644 login/apps/login/src/app/(login)/device/page.tsx create mode 100644 login/apps/login/src/app/(login)/error.tsx create mode 100644 login/apps/login/src/app/(login)/idp/[provider]/failure/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/[provider]/success/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/ldap/page.tsx create mode 100644 login/apps/login/src/app/(login)/idp/page.tsx create mode 100644 login/apps/login/src/app/(login)/layout.tsx create mode 100644 login/apps/login/src/app/(login)/loginname/page.tsx create mode 100644 login/apps/login/src/app/(login)/logout/page.tsx create mode 100644 login/apps/login/src/app/(login)/logout/success/page.tsx create mode 100644 login/apps/login/src/app/(login)/mfa/page.tsx create mode 100644 login/apps/login/src/app/(login)/mfa/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/otp/[method]/page.tsx create mode 100644 login/apps/login/src/app/(login)/otp/[method]/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/page.tsx create mode 100644 login/apps/login/src/app/(login)/passkey/page.tsx create mode 100644 login/apps/login/src/app/(login)/passkey/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/change/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/page.tsx create mode 100644 login/apps/login/src/app/(login)/password/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/register/page.tsx create mode 100644 login/apps/login/src/app/(login)/register/password/page.tsx create mode 100644 login/apps/login/src/app/(login)/saml-post/route.ts create mode 100644 login/apps/login/src/app/(login)/signedin/page.tsx create mode 100644 login/apps/login/src/app/(login)/u2f/page.tsx create mode 100644 login/apps/login/src/app/(login)/u2f/set/page.tsx create mode 100644 login/apps/login/src/app/(login)/verify/page.tsx create mode 100644 login/apps/login/src/app/(login)/verify/success/page.tsx create mode 100644 login/apps/login/src/app/global-error.tsx create mode 100644 login/apps/login/src/app/healthy/route.ts create mode 100644 login/apps/login/src/app/login/route.ts create mode 100644 login/apps/login/src/app/security/route.ts create mode 100644 login/apps/login/src/components/address-bar.tsx create mode 100644 login/apps/login/src/components/alert.tsx create mode 100644 login/apps/login/src/components/app-avatar.tsx create mode 100644 login/apps/login/src/components/auth-methods.tsx create mode 100644 login/apps/login/src/components/authentication-method-radio.tsx create mode 100644 login/apps/login/src/components/avatar.tsx create mode 100644 login/apps/login/src/components/back-button.tsx create mode 100644 login/apps/login/src/components/boundary.tsx create mode 100644 login/apps/login/src/components/button.tsx create mode 100644 login/apps/login/src/components/change-password-form.tsx create mode 100644 login/apps/login/src/components/checkbox.tsx create mode 100644 login/apps/login/src/components/choose-authenticator-to-login.tsx create mode 100644 login/apps/login/src/components/choose-authenticator-to-setup.tsx create mode 100644 login/apps/login/src/components/choose-second-factor-to-setup.tsx create mode 100644 login/apps/login/src/components/choose-second-factor.tsx create mode 100644 login/apps/login/src/components/consent.tsx create mode 100644 login/apps/login/src/components/copy-to-clipboard.tsx create mode 100644 login/apps/login/src/components/default-tags.tsx create mode 100644 login/apps/login/src/components/device-code-form.tsx create mode 100644 login/apps/login/src/components/dynamic-theme.tsx create mode 100644 login/apps/login/src/components/external-link.tsx create mode 100644 login/apps/login/src/components/idp-signin.tsx create mode 100644 login/apps/login/src/components/idps/base-button.tsx create mode 100644 login/apps/login/src/components/idps/pages/complete-idp.tsx create mode 100644 login/apps/login/src/components/idps/pages/linking-failed.tsx create mode 100644 login/apps/login/src/components/idps/pages/linking-success.tsx create mode 100644 login/apps/login/src/components/idps/pages/login-failed.tsx create mode 100644 login/apps/login/src/components/idps/pages/login-success.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-apple.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-azure-ad.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-generic.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-github.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-gitlab.test.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-gitlab.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-google.test.tsx create mode 100644 login/apps/login/src/components/idps/sign-in-with-google.tsx create mode 100644 login/apps/login/src/components/input.tsx create mode 100644 login/apps/login/src/components/language-provider.tsx create mode 100644 login/apps/login/src/components/language-switcher.tsx create mode 100644 login/apps/login/src/components/layout-providers.tsx create mode 100644 login/apps/login/src/components/ldap-username-password-form.tsx create mode 100644 login/apps/login/src/components/login-otp.tsx create mode 100644 login/apps/login/src/components/login-passkey.tsx create mode 100644 login/apps/login/src/components/logo.tsx create mode 100644 login/apps/login/src/components/password-complexity.test.tsx create mode 100644 login/apps/login/src/components/password-complexity.tsx create mode 100644 login/apps/login/src/components/password-form.tsx create mode 100644 login/apps/login/src/components/privacy-policy-checkboxes.tsx create mode 100644 login/apps/login/src/components/register-form-idp-incomplete.tsx create mode 100644 login/apps/login/src/components/register-form.tsx create mode 100644 login/apps/login/src/components/register-passkey.tsx create mode 100644 login/apps/login/src/components/register-u2f.tsx create mode 100644 login/apps/login/src/components/self-service-menu.tsx create mode 100644 login/apps/login/src/components/session-clear-item.tsx create mode 100644 login/apps/login/src/components/session-item.tsx create mode 100644 login/apps/login/src/components/sessions-clear-list.tsx create mode 100644 login/apps/login/src/components/sessions-list.tsx create mode 100644 login/apps/login/src/components/set-password-form.tsx create mode 100644 login/apps/login/src/components/set-register-password-form.tsx create mode 100644 login/apps/login/src/components/sign-in-with-idp.tsx create mode 100644 login/apps/login/src/components/skeleton-card.tsx create mode 100644 login/apps/login/src/components/skeleton.tsx create mode 100644 login/apps/login/src/components/spinner.tsx create mode 100644 login/apps/login/src/components/state-badge.tsx create mode 100644 login/apps/login/src/components/tab-group.tsx create mode 100644 login/apps/login/src/components/tab.tsx create mode 100644 login/apps/login/src/components/theme-provider.tsx create mode 100644 login/apps/login/src/components/theme-wrapper.tsx create mode 100644 login/apps/login/src/components/theme.tsx create mode 100644 login/apps/login/src/components/totp-register.tsx create mode 100644 login/apps/login/src/components/translated.tsx create mode 100644 login/apps/login/src/components/user-avatar.tsx create mode 100644 login/apps/login/src/components/username-form.tsx create mode 100644 login/apps/login/src/components/verify-form.tsx create mode 100644 login/apps/login/src/components/zitadel-logo-dark.tsx create mode 100644 login/apps/login/src/components/zitadel-logo-light.tsx create mode 100644 login/apps/login/src/components/zitadel-logo.tsx create mode 100644 login/apps/login/src/helpers/base64.ts create mode 100644 login/apps/login/src/helpers/colors.ts create mode 100644 login/apps/login/src/helpers/validators.ts create mode 100644 login/apps/login/src/i18n/request.ts create mode 100644 login/apps/login/src/lib/api.ts create mode 100644 login/apps/login/src/lib/client.ts create mode 100644 login/apps/login/src/lib/cookies.ts create mode 100644 login/apps/login/src/lib/demos.ts create mode 100644 login/apps/login/src/lib/fingerprint.ts create mode 100644 login/apps/login/src/lib/hooks.ts create mode 100644 login/apps/login/src/lib/i18n.ts create mode 100644 login/apps/login/src/lib/idp.ts create mode 100644 login/apps/login/src/lib/oidc.ts create mode 100644 login/apps/login/src/lib/saml.ts create mode 100644 login/apps/login/src/lib/self.ts create mode 100644 login/apps/login/src/lib/server/cookie.ts create mode 100644 login/apps/login/src/lib/server/device.ts create mode 100644 login/apps/login/src/lib/server/idp.ts create mode 100644 login/apps/login/src/lib/server/loginname.ts create mode 100644 login/apps/login/src/lib/server/oidc.ts create mode 100644 login/apps/login/src/lib/server/otp.ts create mode 100644 login/apps/login/src/lib/server/passkeys.ts create mode 100644 login/apps/login/src/lib/server/password.ts create mode 100644 login/apps/login/src/lib/server/register.ts create mode 100644 login/apps/login/src/lib/server/session.ts create mode 100644 login/apps/login/src/lib/server/u2f.ts create mode 100644 login/apps/login/src/lib/server/verify.ts create mode 100644 login/apps/login/src/lib/service-url.ts create mode 100644 login/apps/login/src/lib/service.ts create mode 100644 login/apps/login/src/lib/session.ts create mode 100644 login/apps/login/src/lib/verify-helper.ts create mode 100644 login/apps/login/src/lib/zitadel.ts create mode 100644 login/apps/login/src/middleware.ts create mode 100755 login/apps/login/src/styles/globals.scss create mode 100644 login/apps/login/src/styles/vars.scss create mode 100644 login/apps/login/tailwind.config.mjs create mode 100755 login/apps/login/tsconfig.json create mode 100644 login/apps/login/turbo.json create mode 100644 login/apps/login/vitest.config.mts create mode 100644 login/docker-bake.hcl create mode 100644 login/dockerfiles/login-client.Dockerfile create mode 100644 login/dockerfiles/login-client.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-dev-base.Dockerfile create mode 100644 login/dockerfiles/login-dev-base.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-lint.Dockerfile create mode 100644 login/dockerfiles/login-lint.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-pnpm.Dockerfile create mode 100644 login/dockerfiles/login-pnpm.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-standalone.Dockerfile create mode 100644 login/dockerfiles/login-standalone.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-acceptance.Dockerfile create mode 100644 login/dockerfiles/login-test-acceptance.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-integration.Dockerfile create mode 100644 login/dockerfiles/login-test-integration.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-test-unit.Dockerfile create mode 100644 login/dockerfiles/login-test-unit.Dockerfile.dockerignore create mode 100644 login/dockerfiles/login-typescript-proto-client-out.Dockerfile create mode 100644 login/dockerfiles/login-typescript-proto-client-out.Dockerfile.dockerignore create mode 100644 login/dockerfiles/proto-files.Dockerfile create mode 100644 login/dockerfiles/proto-files.Dockerfile.dockerignore create mode 100644 login/dockerfiles/typescript-proto-client.Dockerfile create mode 100644 login/dockerfiles/typescript-proto-client.Dockerfile.dockerignore create mode 100644 login/meta.json create mode 100644 login/package.json create mode 100644 login/packages/zitadel-client/.eslintrc.cjs create mode 100644 login/packages/zitadel-client/.gitignore create mode 100644 login/packages/zitadel-client/CHANGELOG.md create mode 100644 login/packages/zitadel-client/README.md create mode 100644 login/packages/zitadel-client/package.json create mode 100644 login/packages/zitadel-client/src/helpers.ts create mode 100644 login/packages/zitadel-client/src/index.ts create mode 100644 login/packages/zitadel-client/src/interceptors.test.ts create mode 100644 login/packages/zitadel-client/src/interceptors.ts create mode 100644 login/packages/zitadel-client/src/node.ts create mode 100644 login/packages/zitadel-client/src/v1.ts create mode 100644 login/packages/zitadel-client/src/v2.ts create mode 100644 login/packages/zitadel-client/src/v3alpha.ts create mode 100644 login/packages/zitadel-client/src/web.ts create mode 100644 login/packages/zitadel-client/tsconfig.json create mode 100644 login/packages/zitadel-client/tsup.config.ts create mode 100644 login/packages/zitadel-client/turbo.json create mode 100644 login/packages/zitadel-eslint-config/CHANGELOG.md create mode 100644 login/packages/zitadel-eslint-config/README.md create mode 100644 login/packages/zitadel-eslint-config/index.js create mode 100644 login/packages/zitadel-eslint-config/package.json create mode 100644 login/packages/zitadel-prettier-config/CHANGELOG.md create mode 100644 login/packages/zitadel-prettier-config/README.md create mode 100644 login/packages/zitadel-prettier-config/index.js create mode 100644 login/packages/zitadel-prettier-config/package.json create mode 100644 login/packages/zitadel-proto/.gitignore create mode 100644 login/packages/zitadel-proto/CHANGELOG.md create mode 100644 login/packages/zitadel-proto/README.md create mode 100644 login/packages/zitadel-proto/buf.gen.yaml create mode 100644 login/packages/zitadel-proto/package.json create mode 100644 login/packages/zitadel-proto/turbo.json create mode 100644 login/packages/zitadel-tailwind-config/CHANGELOG.md create mode 100644 login/packages/zitadel-tailwind-config/README.md create mode 100644 login/packages/zitadel-tailwind-config/package.json create mode 100644 login/packages/zitadel-tailwind-config/tailwind.config.mjs create mode 100644 login/packages/zitadel-tsconfig/CHANGELOG.md create mode 100644 login/packages/zitadel-tsconfig/README.md create mode 100644 login/packages/zitadel-tsconfig/base.json create mode 100644 login/packages/zitadel-tsconfig/nextjs.json create mode 100644 login/packages/zitadel-tsconfig/node20.json create mode 100644 login/packages/zitadel-tsconfig/package.json create mode 100644 login/packages/zitadel-tsconfig/react-library.json create mode 100644 login/packages/zitadel-tsconfig/tsup.json create mode 100644 login/pnpm-lock.yaml create mode 100644 login/pnpm-workspace.yaml create mode 100755 login/scripts/entrypoint.sh create mode 100644 login/scripts/healthcheck.js create mode 100755 login/scripts/run_or_skip.sh create mode 100644 login/turbo.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b7354f3f4a..8fa71ba652 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,6 +22,19 @@ updates: commit-message: prefix: chore include: scope +- package-ecosystem: npm + directory: '/login' + open-pull-requests-limit: 3 + schedule: + interval: daily + groups: + prod: + dependency-type: production + dev: + dependency-type: development + ignore: + - dependency-name: "eslint" + versions: [ "9.x" ] - package-ecosystem: gomod groups: go: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f06c4a959c..47aa4adef0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,6 +18,8 @@ permissions: packages: write issues: write pull-requests: write + actions: write + id-token: write jobs: core: @@ -47,6 +49,7 @@ jobs: core_cache_path: ${{ needs.core.outputs.cache_path }} console_cache_path: ${{ needs.console.outputs.cache_path }} version: ${{ needs.version.outputs.version }} + node_version: "20" core-unit-test: needs: core @@ -76,6 +79,16 @@ jobs: core_cache_key: ${{ needs.core.outputs.cache_key }} core_cache_path: ${{ needs.core.outputs.cache_path }} + login-quality: + needs: [compile] + uses: ./.github/workflows/login-quality.yml + permissions: + actions: write + id-token: write + with: + ignore-run-cache: ${{ github.event_name == 'workflow_dispatch' }} + node_version: "20" + container: needs: [compile] uses: ./.github/workflows/container.yml @@ -86,6 +99,16 @@ jobs: with: build_image_name: "ghcr.io/zitadel/zitadel-build" + login-container: + uses: ./.github/workflows/login-container.yml + if: ${{ github.event_name == 'workflow_dispatch' }} + permissions: + packages: write + id-token: write + with: + login_build_image_name: "ghcr.io/zitadel/login-build" + node_version: "20" + e2e: uses: ./.github/workflows/e2e.yml needs: [compile] @@ -98,7 +121,7 @@ jobs: issues: write pull-requests: write needs: - [version, core-unit-test, core-integration-test, lint, container, e2e] + [version, core-unit-test, core-integration-test, lint, container, login-container, login-quality, e2e] if: ${{ github.event_name == 'workflow_dispatch' }} secrets: GCR_JSON_KEY_BASE64: ${{ secrets.GCR_JSON_KEY_BASE64 }} @@ -109,3 +132,6 @@ jobs: semantic_version: "23.0.7" image_name: "ghcr.io/zitadel/zitadel" google_image_name: "europe-docker.pkg.dev/zitadel-common/zitadel-repo/zitadel" + build_image_name_login: ${{ needs.login-container.outputs.login_build_image }} + image_name_login: "ghcr.io/zitadel/login" + google_image_name_login: europe-docker.pkg.dev/zitadel-common/zitadel-repo/login diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml index 519586b9ee..7b64427a18 100644 --- a/.github/workflows/compile.yml +++ b/.github/workflows/compile.yml @@ -18,7 +18,9 @@ on: version: required: true type: string - + node_version: + required: true + type: string jobs: executable: runs-on: ubuntu-latest @@ -73,10 +75,38 @@ jobs: with: name: zitadel-${{ matrix.goos }}-${{ matrix.goarch }} path: zitadel-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz - + + login: + runs-on: ubuntu-latest + steps: + - + uses: actions/checkout@v4 + - + uses: depot/setup-action@v1 + with: + oidc: true + - + run: make login_standalone_out + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + NODE_VERSION: ${{ inputs.node_version }} + - + name: move files + run: | + cp login/LICENSE login/apps/login/standalone/ + cp login/README.md login/apps/login/standalone/ + tar -czvf login.tar.gz -C login/apps/login/standalone . + - + uses: actions/upload-artifact@v4 + with: + name: login + path: login.tar.gz + checksums: runs-on: ubuntu-latest - needs: executable + needs: [executable, login] steps: - uses: actions/download-artifact@v4 diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml new file mode 100644 index 0000000000..bce15512af --- /dev/null +++ b/.github/workflows/login-container.yml @@ -0,0 +1,63 @@ +name: Login Container + +on: + workflow_call: + inputs: + login_build_image_name: + description: 'The image repository name of the standalone login image' + type: string + required: true + node_version: + required: true + type: string + outputs: + login_build_image: + description: 'The full image tag of the standalone login image' + value: '${{ inputs.login_build_image_name }}:${{ github.sha }}' + +permissions: + packages: write + +env: + default_labels: | + org.opencontainers.image.documentation=https://zitadel.com/docs + org.opencontainers.image.vendor=CAOS AG + +jobs: + login-container: + name: Build Login Container + runs-on: depot-ubuntu-22.04-8 + permissions: + id-token: write + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Login meta + id: login-meta + uses: docker/metadata-action@v5 + with: + images: ${{ inputs.login_build_image_name }} + labels: ${{ env.default_labels}} + tags: | + type=sha,prefix=,suffix=,format=long + - name: Login to Docker registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Bake login multi-arch + uses: depot/bake-action@v1 + env: + NODE_VERSION: ${{ inputs.node_version }} + with: + workdir: login + push: true + targets: login-standalone + set: login-standalone.platforms=[linux/amd64,linux/arm64] + project: w47wkxzdtw + files: | + ./docker-bake.hcl + cwd://${{ steps.login-meta.outputs.bake-file }} diff --git a/.github/workflows/login-quality.yml b/.github/workflows/login-quality.yml new file mode 100644 index 0000000000..0b4fea73f4 --- /dev/null +++ b/.github/workflows/login-quality.yml @@ -0,0 +1,59 @@ +name: Login Quality + +on: + workflow_call: + inputs: + ignore-run-cache: + description: 'Ignore run caches' + type: boolean + required: true + node_version: + required: true + type: string +jobs: + quality: + name: Ensure Quality + runs-on: depot-ubuntu-22.04-8 + timeout-minutes: 30 + permissions: + id-token: write + actions: write + env: + CACHE_DIR: /tmp/login-run-caches + steps: + - uses: actions/checkout@v4 + - uses: depot/setup-action@v1 + with: + oidc: true + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - uses: actions/download-artifact@v4 + with: + path: .artifacts + name: zitadel-linux-amd64 + - name: Unpack executable + run: | + tar -xvf .artifacts/zitadel-linux-amd64.tar.gz + mv zitadel-linux-amd64/zitadel ./zitadel + - run: make login_quality + env: + # latest if branch is main, otherwise image version which is the pull request number + LOGIN_BAKE_CLI: depot bake + DEPOT_PROJECT_ID: w47wkxzdtw + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache }} + NODE_VERSION: ${{ inputs.node_version }} + + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e40ae8805..e23c8869c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,15 @@ on: google_image_name: required: true type: string + build_image_name_login: + required: true + type: string + image_name_login: + required: true + type: string + google_image_name_login: + required: true + type: string secrets: GCR_JSON_KEY_BASE64: description: 'base64 endcrypted key to connect to Google' @@ -96,6 +105,12 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.google_image_name }}:${{ needs.version.outputs.version }} \ ${{ inputs.build_image_name }} + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} + docker buildx imagetools create \ + --tag ${{ inputs.google_image_name_login }}:${{ needs.version.outputs.version }} \ + ${{ inputs.build_image_name_login }} - name: Publish latest if: ${{ github.ref_name == 'next' }} @@ -106,6 +121,9 @@ jobs: docker buildx imagetools create \ --tag ${{ inputs.image_name }}:latest-debug \ ${{ inputs.build_image_name }}-debug + docker buildx imagetools create \ + --tag ${{ inputs.image_name_login }}:latest \ + ${{ inputs.build_image_name_login }} homebrew-tap: runs-on: ubuntu-22.04 @@ -146,3 +164,55 @@ jobs: GH_TOKEN: ${{ steps.generate-token.outputs.token }} run: | gh workflow -R zitadel/zitadel-charts run bump.yml + + typescript-packages: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + working-directory: login + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + version: ${{ needs.version.outputs.version }} + cwd: login + + typescript-repo: + runs-on: ubuntu-latest + needs: version + if: ${{ github.ref_name == 'next' }} + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Push Subtree + run: make login_push LOGIN_REMOTE_BRANCH=mirror-zitadel-repo + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: 'chore: mirror zitadel repo' + branch: mirror-zitadel-repo + title: 'chore: mirror zitadel repo' + body: 'This PR updates the login repository with the latest changes from the zitadel repository.' + base: main + reviewers: | + @peintnermax + @eliobischof diff --git a/.golangci.yaml b/.golangci.yaml index 1cae359605..a4d5fd95d4 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -20,6 +20,7 @@ issues: - openapi - proto - tools + - login run: concurrency: 4 diff --git a/LICENSING.md b/LICENSING.md index 9cad2082f8..259a0d5070 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -18,6 +18,13 @@ The following files and directories, including their subdirectories, are license proto/ ``` + +The following files and directories, including their subdirectories, are licensed under the [MIT License](https://opensource.org/license/mit/): + +``` +login/ +``` + ## Community Contributions To maintain a clear licensing structure and facilitate community contributions, all contributions must be licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0) to be accepted. By submitting a contribution, you agree to this licensing. diff --git a/Makefile b/Makefile index 3c50231bee..10f52b7c4c 100644 --- a/Makefile +++ b/Makefile @@ -12,11 +12,21 @@ ZITADEL_MASTERKEY ?= MasterkeyNeedsToHave32Characters export GOCOVERDIR ZITADEL_MASTERKEY +LOGIN_REMOTE_NAME := login +LOGIN_REMOTE_URL ?= https://github.com/zitadel/typescript.git +LOGIN_REMOTE_BRANCH ?= main + .PHONY: compile compile: core_build console_build compile_pipeline .PHONY: docker_image -docker_image: compile +docker_image: + @if [ ! -f ./zitadel ]; then \ + echo "Compiling zitadel binary"; \ + $(MAKE) compile; \ + else \ + echo "Reusing precompiled zitadel binary"; \ + fi DOCKER_BUILDKIT=1 docker build -f build/Dockerfile -t $(ZITADEL_IMAGE) . .PHONY: compile_pipeline @@ -165,3 +175,41 @@ core_lint: --config ./.golangci.yaml \ --out-format=github-actions \ --concurrency=$$(getconf _NPROCESSORS_ONLN) + +.PHONY: login_pull +login_pull: login_ensure_remote + @echo "Pulling changes from the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git fetch $(LOGIN_REMOTE_NAME) + git subtree pull --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +.PHONY: login_push +login_push: login_ensure_remote + @echo "Pushing changes to the 'login' subtree on remote $(LOGIN_REMOTE_NAME) branch $(LOGIN_REMOTE_BRANCH)" + git subtree push --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH) + +login_ensure_remote: + @if ! git remote get-url $(LOGIN_REMOTE_NAME) > /dev/null 2>&1; then \ + echo "Adding remote $(LOGIN_REMOTE_NAME)"; \ + git remote add $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_URL); \ + else \ + echo "Remote $(LOGIN_REMOTE_NAME) already exists."; \ + fi + @if [ ! -d login ]; then \ + echo "Adding subtree for 'login' from branch $(LOGIN_REMOTE_BRANCH)"; \ + git subtree add --prefix=login $(LOGIN_REMOTE_NAME) $(LOGIN_REMOTE_BRANCH); \ + else \ + echo "Subtree 'login' already exists."; \ + fi + +export LOGIN_DIR := ./login/ +export LOGIN_BAKE_CLI_ADDITIONAL_ARGS := --set login-*.context=./login/ --file ./docker-bake.hcl +export ZITADEL_TAG ?= $(ZITADEL_IMAGE) +include login/Makefile + +# Intentional override of login_test_acceptance_build +login_test_acceptance_build: docker_image + @echo "Building login test acceptance environment with the local zitadel image" + $(MAKE) login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_dev: docker_image typescript_generate login_test_acceptance_build_compose login_test_acceptance_cleanup login_test_acceptance_setup_dev + @echo "Starting login test environment with the local zitadel image" diff --git a/build/Dockerfile.gitignore b/build/Dockerfile.gitignore new file mode 100644 index 0000000000..a2cc8ed480 --- /dev/null +++ b/build/Dockerfile.gitignore @@ -0,0 +1,3 @@ +* +!build/entrypoint.sh +!zitadel diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index 7bb44b743f..9697e354c5 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -526,13 +526,13 @@ OIDC: CharSet: "BCDFGHJKLMNPQRSTVWXZ" # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARSET CharAmount: 8 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_CHARARMOUNT DashInterval: 4 # ZITADEL_OIDC_DEVICEAUTH_USERCODE_DASHINTERVAL - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 - DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/ui/v2/login/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 PublicKeyCacheMaxAge: 24h # ZITADEL_OIDC_PUBLICKEYCACHEMAXAGE DefaultBackChannelLogoutLifetime: 15m # ZITADEL_OIDC_DEFAULTBACKCHANNELLOGOUTLIFETIME SAML: - DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 + DefaultLoginURLV2: "/ui/v2/login/login?samlRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 ProviderConfig: MetadataConfig: Path: "/metadata" # ZITADEL_SAML_PROVIDERCONFIG_METADATACONFIG_PATH @@ -1131,8 +1131,8 @@ DefaultInstance: # OIDCSingleV1SessionTermination: false # ZITADEL_DEFAULTINSTANCE_FEATURES_OIDCSINGLEV1SESSIONTERMINATION # DisableUserTokenEvent: false # ZITADEL_DEFAULTINSTANCE_FEATURES_DISABLEUSERTOKENEVENT # EnableBackChannelLogout: false # ZITADEL_DEFAULTINSTANCE_FEATURES_ENABLEBACKCHANNELLOGOUT - # LoginV2: - # Required: false # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED + LoginV2: + Required: true # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_REQUIRED # BaseURI: "" # ZITADEL_DEFAULTINSTANCE_FEATURES_LOGINV2_BASEURI # PermissionCheckV2: false # ZITADEL_DEFAULTINSTANCE_FEATURES_PERMISSIONCHECKV2 # ConsoleUseV2UserApi: false # ZITADEL_DEFAULTINSTANCE_FEATURES_CONSOLEUSEV2USERAPI diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000000..d75373dee1 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,5 @@ +target "typescript-proto-client" { + contexts = { + proto-files = "target:proto-files" + } +} diff --git a/dockerfiles/proto-files.Dockerfile b/dockerfiles/proto-files.Dockerfile new file mode 100644 index 0000000000..0af3346096 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile @@ -0,0 +1,8 @@ +FROM bufbuild/buf:1.54.0 AS proto-files +RUN buf export https://github.com/envoyproxy/protoc-gen-validate.git --path validate --output /proto-files && \ + buf export https://github.com/grpc-ecosystem/grpc-gateway.git --path protoc-gen-openapiv2 --output /proto-files && \ + buf export https://github.com/googleapis/googleapis.git --path google/api/annotations.proto --path google/api/http.proto --path google/api/field_behavior.proto --output /proto-files + +FROM scratch +COPY --from=proto-files /proto-files / +COPY ./proto / diff --git a/dockerfiles/proto-files.Dockerfile.dockerignore b/dockerfiles/proto-files.Dockerfile.dockerignore new file mode 100644 index 0000000000..e26cd3c2d6 --- /dev/null +++ b/dockerfiles/proto-files.Dockerfile.dockerignore @@ -0,0 +1,2 @@ +* +!proto diff --git a/dockerfiles/typescript-proto-client.Dockerfile b/dockerfiles/typescript-proto-client.Dockerfile new file mode 100644 index 0000000000..4a9505d19d --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile @@ -0,0 +1,8 @@ +FROM login-pnpm AS typescript-proto-client +COPY ./login/packages/zitadel-proto/package.json ./packages/zitadel-proto/ +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile --workspace-root --filter zitadel-proto +COPY --from=proto-files /buf.yaml /buf.lock /proto-files/ +COPY --from=proto-files /zitadel /proto-files/zitadel +COPY ./login/packages/zitadel-proto/buf.gen.yaml ./packages/zitadel-proto/ +RUN cd packages/zitadel-proto && pnpm exec buf generate /proto-files diff --git a/dockerfiles/typescript-proto-client.Dockerfile.dockerignore b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore new file mode 100644 index 0000000000..3915a26e4e --- /dev/null +++ b/dockerfiles/typescript-proto-client.Dockerfile.dockerignore @@ -0,0 +1,11 @@ +* +!/login/packages/zitadel-proto/ +login/packages/zitadel-proto/google +login/packages/zitadel-proto/zitadel +login/packages/zitadel-proto/protoc-gen-openapiv2 +login/packages/zitadel-proto/validate + +**/*.md +**/*.png +**/node_modules +**/.turbo diff --git a/e2e/config/host.docker.internal/zitadel.yaml b/e2e/config/host.docker.internal/zitadel.yaml index 203dd16437..23f35302b4 100644 --- a/e2e/config/host.docker.internal/zitadel.yaml +++ b/e2e/config/host.docker.internal/zitadel.yaml @@ -60,6 +60,9 @@ Projections: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/e2e/config/localhost/zitadel.yaml b/e2e/config/localhost/zitadel.yaml index 966bb4f6b7..701e7b806b 100644 --- a/e2e/config/localhost/zitadel.yaml +++ b/e2e/config/localhost/zitadel.yaml @@ -52,6 +52,9 @@ Quotas: DefaultInstance: LoginPolicy: MfaInitSkipLifetime: "0" + Features: + LoginV2: + Required: false SystemAPIUsers: - cypress: diff --git a/internal/integration/config/zitadel.yaml b/internal/integration/config/zitadel.yaml index bb8d86376d..fed746d823 100644 --- a/internal/integration/config/zitadel.yaml +++ b/internal/integration/config/zitadel.yaml @@ -101,3 +101,10 @@ SystemDefaults: KeyConfig: PrivateKeyLifetime: 7200h PublicKeyLifetime: 14400h + +OIDC: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_OIDC_DEFAULTLOGINURLV2 + DefaultLogoutURLV2: "/logout?post_logout_redirect=" # ZITADEL_OIDC_DEFAULTLOGOUTURLV2 + +SAML: + DefaultLoginURLV2: "/login?authRequest=" # ZITADEL_SAML_DEFAULTLOGINURLV2 diff --git a/login/.changeset/README.md b/login/.changeset/README.md new file mode 100644 index 0000000000..e5b6d8d6a6 --- /dev/null +++ b/login/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/login/.changeset/config.json b/login/.changeset/config.json new file mode 100644 index 0000000000..3f2d313f66 --- /dev/null +++ b/login/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["@zitadel/login"] +} diff --git a/login/.eslintrc.cjs b/login/.eslintrc.cjs new file mode 100644 index 0000000000..1bfcec169d --- /dev/null +++ b/login/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + // This tells ESLint to load the config from the package `@zitadel/eslint-config` + extends: ["@zitadel/eslint-config"], + settings: { + next: { + rootDir: ["apps/*/"], + }, + }, +}; diff --git a/login/.github/ISSUE_TEMPLATE/bug.yaml b/login/.github/ISSUE_TEMPLATE/bug.yaml new file mode 100644 index 0000000000..2764c1a365 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/bug.yaml @@ -0,0 +1,63 @@ +name: 🐛 Bug Report +description: "Create a bug report to help us improve ZITADEL Typescript Library." +title: "[Bug]: " +labels: ["bug"] +body: +- type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! +- type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the documentation, the existing issues or discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + validations: + required: true +- type: input + id: version + attributes: + label: Version + description: Which version of ZITADEL Typescript Library are you using. +- type: textarea + id: impact + attributes: + label: Describe the problem caused by this bug + description: A clear and concise description of the problem you have and what the bug is. + validations: + required: true +- type: textarea + id: reproduce + attributes: + label: To reproduce + description: Steps to reproduce the behaviour + placeholder: | + Steps to reproduce the behavior: + 1. ... + validations: + required: true +- type: textarea + id: screenshots + attributes: + label: Screenshots + description: If applicable, add screenshots to help explain your problem. +- type: textarea + id: expected + attributes: + label: Expected behavior + description: A clear and concise description of what you expected to happen. +- type: textarea + id: config + attributes: + label: Relevant Configuration + description: Add any relevant configurations that could help us. Make sure to redact any sensitive information. +- type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/config.yml b/login/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..7e690b9344 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,4 @@ +blank_issues_enabled: true +contact_links: + - name: 💬 ZITADEL Community Chat + url: https://zitadel.com/chat diff --git a/login/.github/ISSUE_TEMPLATE/docs.yaml b/login/.github/ISSUE_TEMPLATE/docs.yaml new file mode 100644 index 0000000000..04c1c0cdb1 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/docs.yaml @@ -0,0 +1,30 @@ +name: 📄 Documentation +description: Create an issue for missing or wrong documentation. +labels: ["docs"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this issue. + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: docs + attributes: + label: Describe the docs your are missing or that are wrong + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/improvement.yaml b/login/.github/ISSUE_TEMPLATE/improvement.yaml new file mode 100644 index 0000000000..cfe79d407b --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/improvement.yaml @@ -0,0 +1,54 @@ +name: 🛠️ Improvement +description: "Create an new issue for an improvment in ZITADEL" +labels: ["improvement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this improvement request + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this improvement is supposed to solve. + placeholder: Describe the problem you have + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the typescript library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/ISSUE_TEMPLATE/proposal.yaml b/login/.github/ISSUE_TEMPLATE/proposal.yaml new file mode 100644 index 0000000000..cd9ff66972 --- /dev/null +++ b/login/.github/ISSUE_TEMPLATE/proposal.yaml @@ -0,0 +1,54 @@ +name: 💡 Proposal / Feature request +description: "Create an issue for a feature request/proposal." +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this proposal / feature reqeust + - type: checkboxes + id: preflight + attributes: + label: Preflight Checklist + options: + - label: + I could not find a solution in the existing issues, docs, nor discussions + required: true + - label: + I have joined the [ZITADEL chat](https://zitadel.com/chat) + - type: textarea + id: problem + attributes: + label: Describe your problem + description: Please describe your problem this proposal / feature is supposed to solve. + placeholder: Describe the problem you have. + validations: + required: true + - type: textarea + id: solution + attributes: + label: Describe your ideal solution + description: Which solution do you propose? + placeholder: As a [type of user], I want [some goal] so that [some reason]. + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: Which version of the Typescript Library are you using. + - type: dropdown + id: environment + attributes: + label: Environment + description: How do you use ZITADEL? + options: + - ZITADEL Cloud + - Self-hosted + validations: + required: true + - type: textarea + id: additional + attributes: + label: Additional Context + description: Please add any other infos that could be useful. diff --git a/login/.github/custom-i18n.png b/login/.github/custom-i18n.png new file mode 100644 index 0000000000000000000000000000000000000000..2306e62f8709d5b6f756b773410eca411b3b5c2e GIT binary patch literal 85028 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
+Alternatively, use another environment +You can develop against any ZITADEL instance in which you have sufficient rights to execute the following steps. +Just create or overwrite the file `apps/login/.env.local` yourself. +Add your instances base URL to the file at the key `ZITADEL_API_URL`. +Go to your instance and create a service user for the login application. +The login application creates users on your primary organization and reads policy data. +For the sake of simplicity, just make the service user an instance member with the role `IAM_OWNER`. +Create a PAT and copy it to the file `apps/login/.env.local` using the key `ZITADEL_SERVICE_USER_TOKEN`. + +The file should look similar to this: + +``` +ZITADEL_API_URL=https://zitadel-tlx3du.us1.zitadel.cloud +ZITADEL_SERVICE_USER_TOKEN=1S6w48thfWFI2klgfwkCnhXJLf9FQ457E-_3H74ePQxfO3Af0Tm4V5Xi-ji7urIl_xbn-Rk +``` + +
+ +Start the login application in dev mode: + +```sh +pnpm dev +``` + +Open the login application with your favorite browser at `localhost:3000`. +Change the source code and see the changes live in your browser. + +Make sure the application still behaves as expected by running all tests + +```sh +pnpm test +``` + +To satisfy your unique workflow requirements, check out the package.json in the root directory for more detailed scripts. + +### Run Login UI Acceptance tests + +To run the acceptance tests you need a running ZITADEL environment and a component which receives HTTP requests for the emails and sms's. +This component should also be able to return the content of these notifications, as the codes and links are used in the login flows. +There is a basic implementation in Golang available under [the sink package](./acceptance/sink). + +To setup ZITADEL with the additional Sink container for handling the notifications: + +```sh +pnpm run-sink +``` + +Then you can start the acceptance tests with: + +```sh +pnpm test:acceptance +``` + +### Deploy to Vercel + +To deploy your own version on Vercel, navigate to your instance and create a service user. +Then create a personal access token (PAT), copy and set it as ZITADEL_SERVICE_USER_TOKEN, then navigate to your instance +settings and make sure it gets IAM_OWNER permissions. +Finally set your instance url as ZITADEL_API_URL. Make sure to set it without trailing slash. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzitadel%2Ftypescript&env=ZITADEL_API_URL,ZITADEL_SERVICE_USER_TOKEN&root-directory=apps/login&envDescription=Setup%20a%20service%20account%20with%20IAM_LOGIN_CLIENT%20membership%20on%20your%20instance%20and%20provide%20its%20personal%20access%20token.&project-name=zitadel-login&repository-name=zitadel-login) diff --git a/login/acceptance/docker-compose.yaml b/login/acceptance/docker-compose.yaml new file mode 100644 index 0000000000..a68a435e83 --- /dev/null +++ b/login/acceptance/docker-compose.yaml @@ -0,0 +1,71 @@ +services: + zitadel: + user: "${ZITADEL_DEV_UID}" + image: "${ZITADEL_IMAGE:-ghcr.io/zitadel/zitadel:02617cf17fdde849378c1a6b5254bbfb2745b164}" + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled --config /zitadel.yaml --steps /zitadel.yaml' + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + extra_hosts: + - "localhost:host-gateway" + + db: + restart: "always" + image: postgres:17.0-alpine3.19 + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - 5432:5432 + + wait_for_zitadel: + image: curlimages/curl:8.00.1 + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + setup: + user: "${ZITADEL_DEV_UID}" + container_name: setup + image: acceptance-setup:latest + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /apps/login/.env.local + WRITE_TEST_ENVIRONMENT_FILE: /acceptance/tests/.env.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + volumes: + - "./pat:/pat" + - "../apps/login:/apps/login" + - "../acceptance/tests:/acceptance/tests" + depends_on: + wait_for_zitadel: + condition: "service_completed_successfully" + + sink: + image: golang:1.24-alpine + container_name: sink + command: go run /sink/main.go -port '3333' -email '/email' -sms '/sms' -notification '/notification' + ports: + - 3333:3333 + volumes: + - "./sink:/sink" + depends_on: + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/.gitignore b/login/apps/login-test-acceptance/.gitignore new file mode 100644 index 0000000000..6a7425e885 --- /dev/null +++ b/login/apps/login-test-acceptance/.gitignore @@ -0,0 +1 @@ +go-command diff --git a/login/apps/login-test-acceptance/docker-compose-ci.yaml b/login/apps/login-test-acceptance/docker-compose-ci.yaml new file mode 100644 index 0000000000..7a531fcf42 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose-ci.yaml @@ -0,0 +1,59 @@ +services: + + zitadel: + environment: + ZITADEL_EXTERNALDOMAIN: traefik + + traefik: + labels: !reset [] + + setup: + environment: + ZITADEL_API_DOMAIN: traefik + ZITADEL_API_URL: https://traefik + LOGIN_BASE_URL: https://traefik/ui/v2/login/ + SINK_NOTIFICATION_URL: http://sink:3333/notification + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.traefik + + login: + image: "${LOGIN_TAG:-login:local}" + container_name: acceptance-login + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + ports: + - "3000:3000" + environment: + - NODE_TLS_REJECT_UNAUTHORIZED=0 + depends_on: + setup: + condition: service_completed_successfully + + acceptance: + image: "${LOGIN_TEST_ACCEPTANCE_TAG:-login-test-acceptance:local}" + container_name: acceptance + environment: + - CI + - LOGIN_BASE_URL=https://traefik/ui/v2/login/ + - NODE_TLS_REJECT_UNAUTHORIZED=0 + volumes: + - ../login/.env.test.local:/build/apps/login/.env.test.local + - ./test-results:/build/apps/login-test-acceptance/test-results + - ./playwright-report:/build/apps/login-test-acceptance/playwright-report + ports: + - 9323:9323 + ipc: "host" + init: true + depends_on: + login: + condition: "service_healthy" + sink: + condition: service_healthy +# oidcrp: +# condition: service_healthy +# oidcop: +# condition: service_healthy +# samlsp: +# condition: service_healthy +# samlidp: +# condition: service_healthy diff --git a/login/apps/login-test-acceptance/docker-compose.yaml b/login/apps/login-test-acceptance/docker-compose.yaml new file mode 100644 index 0000000000..cb0463fdc8 --- /dev/null +++ b/login/apps/login-test-acceptance/docker-compose.yaml @@ -0,0 +1,237 @@ +services: + + zitadel: + user: "${UID:-1000}:${GID:-1000}" + image: "${ZITADEL_TAG:-ghcr.io/zitadel/zitadel:latest}" + container_name: acceptance-zitadel + command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --config /zitadel.yaml --steps /zitadel.yaml' + labels: + - "traefik.enable=true" + - "traefik.http.routers.zitadel.rule=!PathPrefix(`/ui/v2/login`)" + # - "traefik.http.middlewares.zitadel.headers.customrequestheaders.Host=localhost" +# - "traefik.http.routers.zitadel.middlewares=zitadel@docker" + - "traefik.http.services.zitadel-service.loadbalancer.server.scheme=h2c" + ports: + - "8080:8080" + volumes: + - ./pat:/pat + - ./zitadel.yaml:/zitadel.yaml + depends_on: + db: + condition: "service_healthy" + + db: + restart: "always" + image: ${LOGIN_TEST_ACCEPTANCE_POSTGES_TAG:-postgres:17.0-alpine3.19} + container_name: acceptance-db + environment: + - POSTGRES_USER=zitadel + - PGUSER=zitadel + - POSTGRES_DB=zitadel + - POSTGRES_HOST_AUTH_METHOD=trust + command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all -c shared_buffers=1GB -c work_mem=16MB -c effective_io_concurrency=100 -c wal_level=minimal -c archive_mode=off -c max_wal_senders=0 + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "5432:5432" + + wait-for-zitadel: + image: curlimages/curl:8.00.1 + container_name: acceptance-wait-for-zitadel + command: /bin/sh -c "until curl -s -o /dev/null -i -f http://zitadel:8080/debug/ready; do echo 'waiting' && sleep 1; done; echo 'ready' && sleep 5;" || false + depends_on: + - zitadel + + traefik: + image: "traefik:v3.4" + container_name: "acceptance-traefik" + labels: + - "traefik.enable=true" + - "traefik.http.routers.login.rule=PathPrefix(`/ui/v2/login`)" + - "traefik.http.services.login-service.loadbalancer.server.url=http://host.docker.internal:3000" + command: +# - "--log.level=DEBUG" + - "--ping" + - "--api.insecure=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.websecure.http.tls=true" + - "--entryPoints.websecure.address=:443" + healthcheck: + test: ["CMD", "traefik", "healthcheck", "--ping"] + interval: "10s" + timeout: "30s" + retries: 5 + start_period: "20s" + ports: + - "443:443" + - "8090:8080" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + extra_hosts: + - host.docker.internal:host-gateway + + setup: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_SETUP_TAG:-login-test-acceptance-setup:local} + container_name: acceptance-setup + restart: no + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/setup" + dockerfile: ../go-command.Dockerfile + entrypoint: "./setup.sh" + environment: + PAT_FILE: /pat/zitadel-admin-sa.pat + ZITADEL_API_INTERNAL_URL: http://zitadel:8080 + WRITE_ENVIRONMENT_FILE: /login-env/.env.test.local + SINK_EMAIL_INTERNAL_URL: http://sink:3333/email + SINK_SMS_INTERNAL_URL: http://sink:3333/sms + SINK_NOTIFICATION_URL: http://localhost:3333/notification + LOGIN_BASE_URL: https://127.0.0.1.sslip.io/ui/v2/login/ + ZITADEL_API_URL: https://127.0.0.1.sslip.io + ZITADEL_API_DOMAIN: 127.0.0.1.sslip.io + ZITADEL_ADMIN_USER: zitadel-admin@zitadel.127.0.0.1.sslip.io + volumes: + - ./pat:/pat # Read the PAT file from zitadels setup + - ../login:/login-env # Write the environment variables file for the login + depends_on: + traefik: + condition: "service_healthy" + wait-for-zitadel: + condition: "service_completed_successfully" + + sink: + image: ${LOGIN_TEST_ACCEPTANCE_SINK_TAG:-login-test-acceptance-sink:local} + container_name: acceptance-sink + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/sink" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + PORT: '3333' + command: + - -port + - '3333' + - -email + - '/email' + - -sms + - '/sms' + - -notification + - '/notification' + ports: + - "3333:3333" + depends_on: + setup: + condition: "service_completed_successfully" + + oidcrp: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG:-login-test-acceptance-oidcrp:local} + container_name: acceptance-oidcrp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/oidcrp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + ISSUER: 'https://traefik' + HOST: 'traefik' + PORT: '8000' + SCOPES: 'openid profile email' + ports: + - "8000:8000" + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + oidcop: + user: "${UID:-1000}:${GID:-1000}" + image: ${LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG:-login-test-acceptance-oidcop:local} + container_name: acceptance-oidcop + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/oidc" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8004" + ports: + - 8004:8004 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlsp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG:-login-test-acceptance-samlsp:local}" + container_name: acceptance-samlsp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/samlsp" + dockerfile: ../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + LOGIN_URL: 'https://traefik/ui/v2/login' + IDP_URL: 'http://zitadel:8080/saml/v2/metadata' + HOST: 'https://traefik' + PORT: '8001' + ports: + - 8001:8001 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" + + samlidp: + user: "${UID:-1000}:${GID:-1000}" + image: "${LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG:-login-test-acceptance-samlidp:local}" + container_name: acceptance-samlidp + build: + context: "${LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT:-.}/idp/saml" + dockerfile: ../../go-command.Dockerfile + args: + - LOGIN_TEST_ACCEPTANCE_GOLANG_TAG=${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG:-golang:1.24-alpine} + environment: + API_URL: 'http://traefik:8080' + API_DOMAIN: 'traefik' + PAT_FILE: '/pat/zitadel-admin-sa.pat' + SCHEMA: 'https' + HOST: 'traefik' + PORT: "8003" + ports: + - 8003:8003 + volumes: + - "./pat:/pat" + depends_on: + traefik: + condition: "service_healthy" + setup: + condition: "service_completed_successfully" diff --git a/login/apps/login-test-acceptance/go-command.Dockerfile b/login/apps/login-test-acceptance/go-command.Dockerfile new file mode 100644 index 0000000000..fafebd6f4d --- /dev/null +++ b/login/apps/login-test-acceptance/go-command.Dockerfile @@ -0,0 +1,11 @@ +ARG LOGIN_TEST_ACCEPTANCE_GOLANG_TAG="golang:1.24-alpine" + +FROM ${LOGIN_TEST_ACCEPTANCE_GOLANG_TAG} +RUN apk add curl jq +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go build -o /go-command . +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s \ + CMD curl -f http://localhost:${PORT}/healthy || exit 1 +ENTRYPOINT [ "/go-command" ] diff --git a/login/apps/login-test-acceptance/idp/oidc/go.mod b/login/apps/login-test-acceptance/idp/oidc/go.mod new file mode 100644 index 0000000000..84dae766c8 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.mod @@ -0,0 +1,28 @@ +module github.com/zitadel/typescript/acceptance/idp/oidc + +go 1.24.1 + +require github.com/zitadel/oidc/v3 v3.37.0 + +require ( + github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect + github.com/go-chi/chi/v5 v5.2.1 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/muhlemmer/httpforwarded v0.1.0 // indirect + github.com/rs/cors v1.11.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/zitadel/logging v0.6.2 // indirect + github.com/zitadel/schema v1.3.1 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/oidc/go.sum b/login/apps/login-test-acceptance/idp/oidc/go.sum new file mode 100644 index 0000000000..42d80d8683 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/go.sum @@ -0,0 +1,71 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= +github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= +github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= +github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/schema v1.3.1 h1:QT3kwiRIRXXLVAs6gCK/u044WmUVh6IlbLXUsn6yRQU= +github.com/zitadel/schema v1.3.1/go.mod h1:071u7D2LQacy1HAN+YnMd/mx1qVE2isb0Mjeqg46xnU= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/idp/oidc/main.go b/login/apps/login-test-acceptance/idp/oidc/main.go new file mode 100644 index 0000000000..b04ac94234 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/oidc/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/zitadel/oidc/v3/example/server/exampleop" + "github.com/zitadel/oidc/v3/example/server/storage" +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + + issuer := fmt.Sprintf("%s://%s:%s/", schema, host, port) + redirectURI := fmt.Sprintf("%s/idps/callback", apiURL) + + clientID := "web" + clientSecret := "secret" + storage.RegisterClients( + storage.WebClient(clientID, clientSecret, redirectURI), + ) + + storage := storage.NewStorage(storage.NewUserStore(issuer)) + router := exampleop.SetupServer(issuer, storage, logger, false) + + server := &http.Server{ + Addr: ":" + port, + Handler: router, + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, issuer, clientID, clientSecret string) error { + idpID, err := CreateIDP(apiURL, pat, domain, issuer, clientID, clientSecret) + if err != nil { + return err + } + return ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + Issuer string `json:"issuer"` + ClientId string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + Scopes []string `json:"scopes"` + ProviderOptions providerOptions `json:"providerOptions"` + IsIdTokenMapping bool `json:"isIdTokenMapping"` + UsePkce bool `json:"usePkce"` +} + +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, issuer, clientID, clientSecret string) (string, error) { + createIDP := &createIDP{ + Name: "OIDC", + Issuer: issuer, + ClientId: clientID, + ClientSecret: clientSecret, + Scopes: []string{"openid", "profile", "email"}, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + IsIdTokenMapping: false, + UsePkce: false, + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/generic_oidc", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/idp/saml/go.mod b/login/apps/login-test-acceptance/idp/saml/go.mod new file mode 100644 index 0000000000..e73b4feb3b --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.mod @@ -0,0 +1,16 @@ +module github.com/zitadel/typescript/acceptance/idp/saml + +go 1.24.1 + +require ( + github.com/crewjam/saml v0.4.14 + github.com/mattermost/xml-roundtrip-validator v0.1.0 + github.com/zenazn/goji v1.0.1 + golang.org/x/crypto v0.36.0 +) + +require ( + github.com/beevik/etree v1.1.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/russellhaering/goxmldsig v1.3.0 // indirect +) diff --git a/login/apps/login-test-acceptance/idp/saml/go.sum b/login/apps/login-test-acceptance/idp/saml/go.sum new file mode 100644 index 0000000000..1208550f6e --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/go.sum @@ -0,0 +1,49 @@ +github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= +github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3cigIwLonTPM= +github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8= +github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/idp/saml/main.go b/login/apps/login-test-acceptance/idp/saml/main.go new file mode 100644 index 0000000000..059eab79e2 --- /dev/null +++ b/login/apps/login-test-acceptance/idp/saml/main.go @@ -0,0 +1,328 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "encoding/xml" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/crewjam/saml" + "github.com/crewjam/saml/logger" + "github.com/crewjam/saml/samlidp" + xrv "github.com/mattermost/xml-roundtrip-validator" + "github.com/zenazn/goji" + "github.com/zenazn/goji/bind" + "github.com/zenazn/goji/web" + "golang.org/x/crypto/bcrypt" +) + +var key = func() crypto.PrivateKey { + b, _ := pem.Decode([]byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0OhbMuizgtbFOfwbK7aURuXhZx6VRuAs3nNibiuifwCGz6u9 +yy7bOR0P+zqN0YkjxaokqFgra7rXKCdeABmoLqCC0U+cGmLNwPOOA0PaD5q5xKhQ +4Me3rt/R9C4Ca6k3/OnkxnKwnogcsmdgs2l8liT3qVHP04Oc7Uymq2v09bGb6nPu +fOrkXS9F6mSClxHG/q59AGOWsXK1xzIRV1eu8W2SNdyeFVU1JHiQe444xLoPul5t +InWasKayFsPlJfWNc8EoU8COjNhfo/GovFTHVjh9oUR/gwEFVwifIHihRE0Hazn2 +EQSLaOr2LM0TsRsQroFjmwSGgI+X2bfbMTqWOQIDAQABAoIBAFWZwDTeESBdrLcT +zHZe++cJLxE4AObn2LrWANEv5AeySYsyzjRBYObIN9IzrgTb8uJ900N/zVr5VkxH +xUa5PKbOcowd2NMfBTw5EEnaNbILLm+coHdanrNzVu59I9TFpAFoPavrNt/e2hNo +NMGPSdOkFi81LLl4xoadz/WR6O/7N2famM+0u7C2uBe+TrVwHyuqboYoidJDhO8M +w4WlY9QgAUhkPyzZqrl+VfF1aDTGVf4LJgaVevfFCas8Ws6DQX5q4QdIoV6/0vXi +B1M+aTnWjHuiIzjBMWhcYW2+I5zfwNWRXaxdlrYXRukGSdnyO+DH/FhHePJgmlkj +NInADDkCgYEA6MEQFOFSCc/ELXYWgStsrtIlJUcsLdLBsy1ocyQa2lkVUw58TouW +RciE6TjW9rp31pfQUnO2l6zOUC6LT9Jvlb9PSsyW+rvjtKB5PjJI6W0hjX41wEO6 +fshFELMJd9W+Ezao2AsP2hZJ8McCF8no9e00+G4xTAyxHsNI2AFTCQcCgYEA5cWZ +JwNb4t7YeEajPt9xuYNUOQpjvQn1aGOV7KcwTx5ELP/Hzi723BxHs7GSdrLkkDmi +Gpb+mfL4wxCt0fK0i8GFQsRn5eusyq9hLqP/bmjpHoXe/1uajFbE1fZQR+2LX05N +3ATlKaH2hdfCJedFa4wf43+cl6Yhp6ZA0Yet1r8CgYEAwiu1j8W9G+RRA5/8/DtO +yrUTOfsbFws4fpLGDTA0mq0whf6Soy/96C90+d9qLaC3srUpnG9eB0CpSOjbXXbv +kdxseLkexwOR3bD2FHX8r4dUM2bzznZyEaxfOaQypN8SV5ME3l60Fbr8ajqLO288 +wlTmGM5Mn+YCqOg/T7wjGmcCgYBpzNfdl/VafOROVbBbhgXWtzsz3K3aYNiIjbp+ +MunStIwN8GUvcn6nEbqOaoiXcX4/TtpuxfJMLw4OvAJdtxUdeSmEee2heCijV6g3 +ErrOOy6EqH3rNWHvlxChuP50cFQJuYOueO6QggyCyruSOnDDuc0BM0SGq6+5g5s7 +H++S/wKBgQDIkqBtFr9UEf8d6JpkxS0RXDlhSMjkXmkQeKGFzdoJcYVFIwq8jTNB +nJrVIGs3GcBkqGic+i7rTO1YPkquv4dUuiIn+vKZVoO6b54f+oPBXd4S0BnuEqFE +rdKNuCZhiaE2XD9L/O9KP1fh5bfEcKwazQ23EvpJHBMm8BGC+/YZNw== +-----END RSA PRIVATE KEY-----`)) + k, _ := x509.ParsePKCS1PrivateKey(b.Bytes) + return k +}() + +var cert = func() *x509.Certificate { + b, _ := pem.Decode([]byte(`-----BEGIN CERTIFICATE----- +MIIDBzCCAe+gAwIBAgIJAPr/Mrlc8EGhMA0GCSqGSIb3DQEBBQUAMBoxGDAWBgNV +BAMMD3d3dy5leGFtcGxlLmNvbTAeFw0xNTEyMjgxOTE5NDVaFw0yNTEyMjUxOTE5 +NDVaMBoxGDAWBgNVBAMMD3d3dy5leGFtcGxlLmNvbTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBANDoWzLos4LWxTn8Gyu2lEbl4WcelUbgLN5zYm4ron8A +hs+rvcsu2zkdD/s6jdGJI8WqJKhYK2u61ygnXgAZqC6ggtFPnBpizcDzjgND2g+a +ucSoUODHt67f0fQuAmupN/zp5MZysJ6IHLJnYLNpfJYk96lRz9ODnO1Mpqtr9PWx +m+pz7nzq5F0vRepkgpcRxv6ufQBjlrFytccyEVdXrvFtkjXcnhVVNSR4kHuOOMS6 +D7pebSJ1mrCmshbD5SX1jXPBKFPAjozYX6PxqLxUx1Y4faFEf4MBBVcInyB4oURN +B2s59hEEi2jq9izNE7EbEK6BY5sEhoCPl9m32zE6ljkCAwEAAaNQME4wHQYDVR0O +BBYEFB9ZklC1Ork2zl56zg08ei7ss/+iMB8GA1UdIwQYMBaAFB9ZklC1Ork2zl56 +zg08ei7ss/+iMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADggEBAAVoTSQ5 +pAirw8OR9FZ1bRSuTDhY9uxzl/OL7lUmsv2cMNeCB3BRZqm3mFt+cwN8GsH6f3uv +NONIhgFpTGN5LEcXQz89zJEzB+qaHqmbFpHQl/sx2B8ezNgT/882H2IH00dXESEf +y/+1gHg2pxjGnhRBN6el/gSaDiySIMKbilDrffuvxiCfbpPN0NRRiPJhd2ay9KuL +/RxQRl1gl9cHaWiouWWba1bSBb2ZPhv2rPMUsFo98ntkGCObDX6Y1SpkqmoTbrsb +GFsTG2DLxnvr4GdN1BSr0Uu/KV3adj47WkXVPeMYQti/bQmxQB8tRFhrw80qakTL +UzreO96WzlBBMtY= +-----END CERTIFICATE-----`)) + c, _ := x509.ParseCertificate(b.Bytes) + return c +}() + +// Example from https://github.com/crewjam/saml/blob/main/example/idp/idp.go +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + schema := os.Getenv("SCHEMA") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + baseURL, err := url.Parse(schema + "://" + host + ":" + port) + if err != nil { + + panic(err) + } + + idpServer, err := samlidp.New(samlidp.Options{ + URL: *baseURL, + Logger: logger.DefaultLogger, + Key: key, + Certificate: cert, + Store: &samlidp.MemoryStore{}, + }) + if err != nil { + + panic(err) + } + + metadata, err := xml.MarshalIndent(idpServer.IDP.Metadata(), "", " ") + if err != nil { + panic(err) + } + idpID, err := createZitadelResources(apiURL, pat, domain, metadata) + if err != nil { + panic(err) + } + + lis := bind.Socket(":" + baseURL.Port()) + goji.Handle("/*", idpServer) + + go func() { + goji.ServeListener(lis) + }() + + addService(idpServer, apiURL+"/idps/"+idpID+"/saml/metadata") + addUsers(idpServer) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + if err := lis.Close(); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func addService(idpServer *samlidp.Server, spURLStr string) { + metadataResp, err := http.Get(spURLStr) + if err != nil { + panic(err) + } + defer metadataResp.Body.Close() + + idpServer.HandlePutService( + web.C{URLParams: map[string]string{"id": spURLStr}}, + httptest.NewRecorder(), + httptest.NewRequest(http.MethodPost, spURLStr, metadataResp.Body), + ) +} + +func getSPMetadata(r io.Reader) (spMetadata *saml.EntityDescriptor, err error) { + var data []byte + if data, err = io.ReadAll(r); err != nil { + return nil, err + } + + spMetadata = &saml.EntityDescriptor{} + if err := xrv.Validate(bytes.NewBuffer(data)); err != nil { + return nil, err + } + + if err := xml.Unmarshal(data, &spMetadata); err != nil { + if err.Error() == "expected element type but have " { + entities := &saml.EntitiesDescriptor{} + if err := xml.Unmarshal(data, &entities); err != nil { + return nil, err + } + + for _, e := range entities.EntityDescriptors { + if len(e.SPSSODescriptors) > 0 { + return &e, nil + } + } + + // there were no SPSSODescriptors in the response + return nil, errors.New("metadata contained no service provider metadata") + } + + return nil, err + } + + return spMetadata, nil +} + +func addUsers(idpServer *samlidp.Server) { + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("hunter2"), bcrypt.DefaultCost) + err := idpServer.Store.Put("/users/alice", samlidp.User{Name: "alice", + HashedPassword: hashedPassword, + Groups: []string{"Administrators", "Users"}, + Email: "alice@example.com", + CommonName: "Alice Smith", + Surname: "Smith", + GivenName: "Alice", + }) + if err != nil { + panic(err) + } + + err = idpServer.Store.Put("/users/bob", samlidp.User{ + Name: "bob", + HashedPassword: hashedPassword, + Groups: []string{"Users"}, + Email: "bob@example.com", + CommonName: "Bob Smith", + Surname: "Smith", + GivenName: "Bob", + }) + if err != nil { + panic(err) + } +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte) (string, error) { + idpID, err := CreateIDP(apiURL, pat, domain, metadata) + if err != nil { + return "", err + } + return idpID, ActivateIDP(apiURL, pat, domain, idpID) +} + +type createIDP struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + Binding string `json:"binding"` + WithSignedRequest bool `json:"withSignedRequest"` + ProviderOptions providerOptions `json:"providerOptions"` + NameIdFormat string `json:"nameIdFormat"` +} +type providerOptions struct { + IsLinkingAllowed bool `json:"isLinkingAllowed"` + IsCreationAllowed bool `json:"isCreationAllowed"` + IsAutoCreation bool `json:"isAutoCreation"` + IsAutoUpdate bool `json:"isAutoUpdate"` + AutoLinking string `json:"autoLinking"` +} + +type idp struct { + ID string `json:"id"` +} + +func CreateIDP(apiURL, pat, domain string, idpMetadata []byte) (string, error) { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(idpMetadata))) + base64.URLEncoding.Encode(encoded, idpMetadata) + + createIDP := &createIDP{ + Name: "CREWJAM", + MetadataXml: string(encoded), + Binding: "SAML_BINDING_REDIRECT", + WithSignedRequest: false, + ProviderOptions: providerOptions{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + AutoLinking: "AUTO_LINKING_OPTION_USERNAME", + }, + NameIdFormat: "SAML_NAME_ID_FORMAT_PERSISTENT", + } + + resp, err := doRequestWithHeaders(apiURL+"/admin/v1/idps/saml", pat, domain, createIDP) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + idp := new(idp) + if err := json.Unmarshal(data, idp); err != nil { + return "", err + } + return idp.ID, nil +} + +type activateIDP struct { + IdpId string `json:"idpId"` +} + +func ActivateIDP(apiURL, pat, domain string, idpID string) error { + activateIDP := &activateIDP{ + IdpId: idpID, + } + _, err := doRequestWithHeaders(apiURL+"/admin/v1/policies/login/idps", pat, domain, activateIDP) + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/oidcrp/go.mod b/login/apps/login-test-acceptance/oidcrp/go.mod new file mode 100644 index 0000000000..f2cda3058e --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.mod @@ -0,0 +1,26 @@ +module github.com/zitadel/typescript/acceptance/oidc + +go 1.24.1 + +require ( + github.com/google/uuid v1.6.0 + github.com/sirupsen/logrus v1.9.3 + github.com/zitadel/logging v0.6.1 + github.com/zitadel/oidc/v3 v3.36.1 +) + +require ( + github.com/go-jose/go-jose/v4 v4.0.5 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/muhlemmer/gu v0.3.1 // indirect + github.com/zitadel/schema v1.3.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect + golang.org/x/crypto v0.35.0 // indirect + golang.org/x/oauth2 v0.28.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/login/apps/login-test-acceptance/oidcrp/go.sum b/login/apps/login-test-acceptance/oidcrp/go.sum new file mode 100644 index 0000000000..33244ea6eb --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/go.sum @@ -0,0 +1,67 @@ +github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= +github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/jeremija/gosubmit v0.2.8 h1:mmSITBz9JxVtu8eqbN+zmmwX7Ij2RidQxhcwRVI4wqA= +github.com/jeremija/gosubmit v0.2.8/go.mod h1:Ui+HS073lCFREXBbdfrJzMB57OI/bdxTiLtrDHHhFPI= +github.com/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM= +github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM= +github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY= +github.com/muhlemmer/httpforwarded v0.1.0/go.mod h1:yo9czKedo2pdZhoXe+yDkGVbU0TJ0q9oQ90BVoDEtw0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zitadel/logging v0.6.1 h1:Vyzk1rl9Kq9RCevcpX6ujUaTYFX43aa4LkvV1TvUk+Y= +github.com/zitadel/logging v0.6.1/go.mod h1:Y4CyAXHpl3Mig6JOszcV5Rqqsojj+3n7y2F591Mp/ow= +github.com/zitadel/oidc/v3 v3.36.1 h1:1AT1NqKKEqAwx4GmKJZ9fYkWH2WIn/VKMfQ46nBtRf0= +github.com/zitadel/oidc/v3 v3.36.1/go.mod h1:dApGZLvWZTHRuxmcbQlW5d2XVjVYR3vGOdq536igmTs= +github.com/zitadel/schema v1.3.0 h1:kQ9W9tvIwZICCKWcMvCEweXET1OcOyGEuFbHs4o5kg0= +github.com/zitadel/schema v1.3.0/go.mod h1:NptN6mkBDFvERUCvZHlvWmmME+gmZ44xzwRXwhzsbtc= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= +golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/login/apps/login-test-acceptance/oidcrp/main.go b/login/apps/login-test-acceptance/oidcrp/main.go new file mode 100644 index 0000000000..72ae5f57e9 --- /dev/null +++ b/login/apps/login-test-acceptance/oidcrp/main.go @@ -0,0 +1,322 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "sync/atomic" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" + + "github.com/zitadel/logging" + "github.com/zitadel/oidc/v3/pkg/client/rp" + httphelper "github.com/zitadel/oidc/v3/pkg/http" + "github.com/zitadel/oidc/v3/pkg/oidc" +) + +var ( + callbackPath = "/auth/callback" + key = []byte("test1234test1234") +) + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + issuer := os.Getenv("ISSUER") + port := os.Getenv("PORT") + scopeList := strings.Split(os.Getenv("SCOPES"), " ") + + redirectURI := fmt.Sprintf("%s%s", issuer, callbackPath) + cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) + + clientID, clientSecret, err := createZitadelResources(apiURL, pat, domain, redirectURI, loginURL) + if err != nil { + panic(err) + } + + logger := slog.New( + slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelDebug, + }), + ) + client := &http.Client{ + Timeout: time.Minute, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + }, + } + // enable outgoing request logging + logging.EnableHTTPClient(client, + logging.WithClientGroup("client"), + ) + + options := []rp.Option{ + rp.WithCookieHandler(cookieHandler), + rp.WithVerifierOpts(rp.WithIssuedAtOffset(5 * time.Second)), + rp.WithHTTPClient(client), + rp.WithLogger(logger), + rp.WithSigningAlgsFromDiscovery(), + rp.WithCustomDiscoveryUrl(issuer + "/.well-known/openid-configuration"), + } + if clientSecret == "" { + options = append(options, rp.WithPKCE(cookieHandler)) + } + + // One can add a logger to the context, + // pre-defining log attributes as required. + ctx := logging.ToContext(context.TODO(), logger) + provider, err := rp.NewRelyingPartyOIDC(ctx, issuer, clientID, clientSecret, redirectURI, scopeList, options...) + if err != nil { + logrus.Fatalf("error creating provider %s", err.Error()) + } + + // generate some state (representing the state of the user in your application, + // e.g. the page where he was before sending him to login + state := func() string { + return uuid.New().String() + } + + urlOptions := []rp.URLParamOpt{ + rp.WithPromptURLParam("Welcome back!"), + } + + // register the AuthURLHandler at your preferred path. + // the AuthURLHandler creates the auth request and redirects the user to the auth server. + // including state handling with secure cookie and the possibility to use PKCE. + // Prompts can optionally be set to inform the server of + // any messages that need to be prompted back to the user. + http.Handle("/login", rp.AuthURLHandler( + state, + provider, + urlOptions..., + )) + + // for demonstration purposes the returned userinfo response is written as JSON object onto response + marshalUserinfo := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, rp rp.RelyingParty, info *oidc.UserInfo) { + fmt.Println("access token", tokens.AccessToken) + fmt.Println("refresh token", tokens.RefreshToken) + fmt.Println("id token", tokens.IDToken) + + data, err := json.Marshal(info) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("content-type", "application/json") + w.Write(data) + } + + // register the CodeExchangeHandler at the callbackPath + // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function + // with the returned tokens from the token endpoint + // in this example the callback function itself is wrapped by the UserinfoCallback which + // will call the Userinfo endpoint, check the sub and pass the info into the callback function + http.Handle(callbackPath, rp.CodeExchangeHandler(rp.UserinfoCallback(marshalUserinfo), provider)) + + // if you would use the callback without calling the userinfo endpoint, simply switch the callback handler for: + // + // http.Handle(callbackPath, rp.CodeExchangeHandler(marshalToken, provider)) + + // simple counter for request IDs + var counter atomic.Int64 + // enable incomming request logging + mw := logging.Middleware( + logging.WithLogger(logger), + logging.WithGroup("server"), + logging.WithIDFunc(func() slog.Attr { + return slog.Int64("id", counter.Add(1)) + }), + ) + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + server := &http.Server{ + Addr: ":" + port, + Handler: mw(http.DefaultServeMux), + } + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain, redirectURI, loginURL string) (string, string, error) { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return "", "", err + } + return CreateApp(apiURL, pat, domain, projectID, redirectURI, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "OIDC", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + fmt.Printf("projectID: %+v\n", p.ID) + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + RedirectUris []string `json:"redirectUris"` + ResponseTypes []string `json:"responseTypes"` + GrantTypes []string `json:"grantTypes"` + AppType string `json:"appType"` + AuthMethodType string `json:"authMethodType"` + PostLogoutRedirectUris []string `json:"postLogoutRedirectUris"` + Version string `json:"version"` + DevMode bool `json:"devMode"` + AccessTokenType string `json:"accessTokenType"` + AccessTokenRoleAssertion bool `json:"accessTokenRoleAssertion"` + IdTokenRoleAssertion bool `json:"idTokenRoleAssertion"` + IdTokenUserinfoAssertion bool `json:"idTokenUserinfoAssertion"` + ClockSkew string `json:"clockSkew"` + AdditionalOrigins []string `json:"additionalOrigins"` + SkipNativeAppSuccessPage bool `json:"skipNativeAppSuccessPage"` + BackChannelLogoutUri []string `json:"backChannelLogoutUri"` + LoginVersion version `json:"loginVersion"` +} + +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +type app struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` +} + +func CreateApp(apiURL, pat, domain, projectID string, redirectURI, loginURL string) (string, string, error) { + createApp := &createApp{ + Name: "OIDC", + RedirectUris: []string{redirectURI}, + ResponseTypes: []string{"OIDC_RESPONSE_TYPE_CODE"}, + GrantTypes: []string{"OIDC_GRANT_TYPE_AUTHORIZATION_CODE"}, + AppType: "OIDC_APP_TYPE_WEB", + AuthMethodType: "OIDC_AUTH_METHOD_TYPE_BASIC", + Version: "OIDC_VERSION_1_0", + DevMode: true, + AccessTokenType: "OIDC_TOKEN_TYPE_BEARER", + AccessTokenRoleAssertion: true, + IdTokenRoleAssertion: true, + IdTokenUserinfoAssertion: true, + ClockSkew: "1s", + SkipNativeAppSuccessPage: true, + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/oidc", pat, domain, createApp) + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + a := new(app) + if err := json.Unmarshal(data, a); err != nil { + return "", "", err + } + return a.ClientID, a.ClientSecret, err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/package.json b/login/apps/login-test-acceptance/package.json new file mode 100644 index 0000000000..1fb83f0345 --- /dev/null +++ b/login/apps/login-test-acceptance/package.json @@ -0,0 +1,18 @@ +{ + "name": "login-test-acceptance", + "private": true, + "scripts": { + "test:acceptance": "dotenv -e ../login/.env.test.local pnpm exec playwright", + "test:acceptance:setup": "cd ../.. && make login_test_acceptance_setup_env && NODE_ENV=test pnpm exec turbo run test:acceptance:setup:dev", + "test:acceptance:setup:dev": "cd ../.. && make login_test_acceptance_setup_dev" + }, + "devDependencies": { + "@faker-js/faker": "^9.7.0", + "@otplib/core": "^12.0.0", + "@otplib/plugin-crypto": "^12.0.0", + "@otplib/plugin-thirty-two": "^12.0.0", + "@playwright/test": "^1.52.0", + "gaxios": "^7.1.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-acceptance/pat/.gitignore b/login/apps/login-test-acceptance/pat/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/pat/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/pat/.gitkeep b/login/apps/login-test-acceptance/pat/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright-report/.gitignore b/login/apps/login-test-acceptance/playwright-report/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/playwright-report/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/playwright-report/.gitkeep b/login/apps/login-test-acceptance/playwright-report/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/playwright.config.ts b/login/apps/login-test-acceptance/playwright.config.ts new file mode 100644 index 0000000000..8025db3238 --- /dev/null +++ b/login/apps/login-test-acceptance/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; + +dotenv.config({ path: path.resolve(__dirname, "../login/.env.test.local") }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./tests", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + expect: { + timeout: 10_000, // 10 seconds + }, + timeout: 300 * 1000, // 5 minutes + globalTimeout: 30 * 60_000, // 30 minutes + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: [ + ["line"], + ["html", { open: process.env.CI ? "never" : "on-failure", host: "0.0.0.0", outputFolder: "./playwright-report/html" }], + ], + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.LOGIN_BASE_URL || "http://127.0.0.1:3000", + trace: "retain-on-failure", + headless: true, + screenshot: "only-on-failure", + video: "retain-on-failure", + ignoreHTTPSErrors: true, + }, + outputDir: "test-results/results", + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + /* + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + TODO: webkit fails. Is this a bug? + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + */ + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], +}); diff --git a/login/apps/login-test-acceptance/samlsp/go.mod b/login/apps/login-test-acceptance/samlsp/go.mod new file mode 100644 index 0000000000..9986149bfb --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.mod @@ -0,0 +1,18 @@ +module github.com/zitadel/typescript/acceptance/saml + +go 1.24.0 + +require github.com/crewjam/saml v0.4.14 + +require ( + github.com/beevik/etree v1.5.0 // indirect + github.com/crewjam/httperr v0.2.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/russellhaering/goxmldsig v1.5.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/crypto v0.36.0 // indirect +) diff --git a/login/apps/login-test-acceptance/samlsp/go.sum b/login/apps/login-test-acceptance/samlsp/go.sum new file mode 100644 index 0000000000..3394a39410 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/go.sum @@ -0,0 +1,38 @@ +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= +github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo= +github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= +github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= +github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= +github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/login/apps/login-test-acceptance/samlsp/main.go b/login/apps/login-test-acceptance/samlsp/main.go new file mode 100644 index 0000000000..9dcfd13796 --- /dev/null +++ b/login/apps/login-test-acceptance/samlsp/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "bytes" + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/crewjam/saml/samlsp" +) + +var keyPair = func() tls.Certificate { + cert := []byte(`-----BEGIN CERTIFICATE----- +MIIDITCCAgmgAwIBAgIUKjAUmxsHO44X+/TKBNciPgNl1GEwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1wbGUuY29tMB4XDTI0MTIxOTEz +Mzc1MVoXDTI1MTIxOTEzMzc1MVowIDEeMBwGA1UEAwwVbXlzZXJ2aWNlLmV4YW1w +bGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0QYuJsayILRI +hVT7G1DlitVSXnt1iw3gEXJZfe81Egz06fUbvXF6Yo1LJmwYpqe/rm+hf4FNUb8e +2O+LH2FieA9FkVe4P2gKOzw87A/KxvpV8stgNgl4LlqRCokbc1AzeE/NiLr5TcTD +RXm3DUcYxXxinprtDu2jftFysaOZmNAukvE/iL6qS3X6ggVEDDM7tY9n5FV2eJ4E +p0ImKfypi2aZYROxOK+v5x9ryFRMl4y07lMDvmtcV45uXYmfGNCgG9PNf91Kk/mh +JxEQbxycJwFoSi9XWljR8ahPdO11LXG7Dsj/RVbY8k2LdKNstl6Ae3aCpbe9u2Pj +vxYs1bVJuQIDAQABo1MwUTAdBgNVHQ4EFgQU+mRVN5HYJWgnpopReaLhf2cMcoYw +HwYDVR0jBBgwFoAU+mRVN5HYJWgnpopReaLhf2cMcoYwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEABJpHVuc9tGhD04infRVlofvqXIUizTlOrjZX +vozW9pIhSWEHX8o+sJP8AMZLnrsdq+bm0HE0HvgYrw7Lb8pd4FpR46TkFHjeukoj +izqfgckjIBl2nwPGlynbKA0/U/rTCSxVt7XiAn+lgYUGIpOzNdk06/hRMitrMNB7 +t2C97NseVC4b1ZgyFrozsefCfUmD8IJF0+XJ4Wzmsh0jRrI8koCtVmPYnKn6vw1b +cZprg/97CWHYrsavd406wOB60CMtYl83Q16ucOF1dretDFqJC5kY+aFLvuqfag2+ +kIaoPV1MnGsxveQyyHdOsEatS5XOv/1OWcmnvePDPxcvb9jCcw== +-----END CERTIFICATE----- +`) + key := []byte(`-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDRBi4mxrIgtEiF +VPsbUOWK1VJee3WLDeARcll97zUSDPTp9Ru9cXpijUsmbBimp7+ub6F/gU1Rvx7Y +74sfYWJ4D0WRV7g/aAo7PDzsD8rG+lXyy2A2CXguWpEKiRtzUDN4T82IuvlNxMNF +ebcNRxjFfGKemu0O7aN+0XKxo5mY0C6S8T+IvqpLdfqCBUQMMzu1j2fkVXZ4ngSn +QiYp/KmLZplhE7E4r6/nH2vIVEyXjLTuUwO+a1xXjm5diZ8Y0KAb081/3UqT+aEn +ERBvHJwnAWhKL1daWNHxqE907XUtcbsOyP9FVtjyTYt0o2y2XoB7doKlt727Y+O/ +FizVtUm5AgMBAAECggEACak+l5f6Onj+u5vrjc4JyAaXW6ra6loSM9g8Uu3sHukW +plwoA7Pzp0u20CAxrP1Gpqw984/hSCCcb0Q2ItWMWLaC/YZni5W2WFnOyo3pzlPa +hmH4UNMT+ReCSfF/oW8w69QLcNEMjhfEu0i2iWBygIlA4SoRwC2Db6yEX7nLMwUB +6AICid9hfeACNRz/nq5ytdcHdmcB7Ptgb9jLiXr6RZw26g5AsRPHU3LdcyZAOXjP +aUHriHuHQFKAVkoEUxslvCB6ePCTCpB0bSAuzQbeGoY8fmvmNSCvJ1vrH5hiSUYp +Axtl5iNgFl5o9obb0eBYlY9x3pMSz0twdbCwfR7HAQKBgQDtWhmFm0NaJALoY+tq +lIIC0EOMSrcRIlgeXr6+g8womuDOMi5m/Nr5Mqt4mPOdP4HytrQb+a/ZmEm17KHh +mQb1vwH8ffirCBHbPNC1vwSNoxDKv9E6OysWlKiOzxPFSVZr3dKl2EMX6qi17n0l +LBrGXXaNPgYiHSmwBA5CZvvouQKBgQDhclGJfZfuoubQkUuz8yOA2uxalh/iUmQ/ +G8ac6/w7dmnL9pXehqCWh06SeC3ZvW7yrf7IIGx4sTJji2FzQ+8Ta6pPELMyBEXr +1VirIFrlNVMlMQEbZcbzdzEhchM1RUpZJtl3b4amvH21UcRB69d9klcDRisKoFRm +k0P9QLHpAQKBgQDh5J9nphZa4u0ViYtTW1XFIbs3+R/0IbCl7tww67TRbF3KQL4i +7EHna88ALumkXf3qJvKRsXgoaqS0jSqgUAjst8ZHLQkOldaQxneIkezedDSWEisp +9YgTrJYjnHefiyXB8VL63jE0wPOiewEF8Mzmv6sFz+L8cq7rQ2Di16qmmQKBgQDH +bvCwVxkrMpJK2O2GH8U9fOzu6bUE6eviY/jb4mp8U7EdjGJhuuieoM2iBoxQ/SID +rmYftYcfcWlo4+juJZ99p5W+YcCTs3IDQPUyVOnzr6uA0Avxp6RKxhsBQj+5tTUj +Dpn77P3JzB7MYqvhwPcdD3LH46+5s8FWCFpx02RPAQKBgARbngtggfifatcsMC7n +lSv/FVLH7LYQAHdoW/EH5Be7FeeP+eQvGXwh1dgl+u0VZO8FvI8RwFganpBRR2Nc +ZSBRIb0fSUlTvIsckSWjpEvUJUomJXyi4PIZAfNvd9/u1uLInQiCDtObwb6hnLTU +FHHEZ+dR4eMaJp6PhNm8hu2O +-----END PRIVATE KEY----- +`) + + kp, err := tls.X509KeyPair(cert, key) + if err != nil { + panic(err) + } + kp.Leaf, err = x509.ParseCertificate(kp.Certificate[0]) + if err != nil { + panic(err) + } + return kp +}() + +func hello(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "Hello, %s!", samlsp.AttributeFromContext(r.Context(), "UserName")) +} + +func main() { + apiURL := os.Getenv("API_URL") + pat := readPAT(os.Getenv("PAT_FILE")) + domain := os.Getenv("API_DOMAIN") + loginURL := os.Getenv("LOGIN_URL") + idpURL := os.Getenv("IDP_URL") + host := os.Getenv("HOST") + port := os.Getenv("PORT") + + idpMetadataURL, err := url.Parse(idpURL) + if err != nil { + panic(err) + } + idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient, + *idpMetadataURL) + if err != nil { + panic(fmt.Errorf("failed to fetch IDP metadata from %s: %w", idpURL, err)) + } + fmt.Printf("idpMetadata: %+v\n", idpMetadata) + rootURL, err := url.Parse(host + ":" + port) + if err != nil { + panic(err) + } + + samlSP, err := samlsp.New(samlsp.Options{ + URL: *rootURL, + Key: keyPair.PrivateKey.(*rsa.PrivateKey), + Certificate: keyPair.Leaf, + IDPMetadata: idpMetadata, + }) + if err != nil { + panic(err) + } + + server := &http.Server{ + Addr: ":" + port, + } + app := http.HandlerFunc(hello) + http.Handle("/hello", samlSP.RequireAccount(app)) + http.Handle("/saml/", samlSP) + go func() { + if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("HTTP server error: %v", err) + } + log.Println("Stopped serving new connections.") + }() + + metadata, err := xml.MarshalIndent(samlSP.ServiceProvider.Metadata(), "", " ") + if err != nil { + panic(err) + } + if err := createZitadelResources(apiURL, pat, domain, metadata, loginURL); err != nil { + panic(err) + } + + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownRelease() + + if err := server.Shutdown(shutdownCtx); err != nil { + log.Fatalf("HTTP shutdown error: %v", err) + } +} + +func readPAT(path string) string { + f, err := os.Open(path) + if err != nil { + panic(err) + } + pat, err := io.ReadAll(f) + if err != nil { + panic(err) + } + return strings.Trim(string(pat), "\n") +} + +func createZitadelResources(apiURL, pat, domain string, metadata []byte, loginURL string) error { + projectID, err := CreateProject(apiURL, pat, domain) + if err != nil { + return err + } + return CreateApp(apiURL, pat, domain, projectID, metadata, loginURL) +} + +type project struct { + ID string `json:"id"` +} +type createProject struct { + Name string `json:"name"` + ProjectRoleAssertion bool `json:"projectRoleAssertion"` + ProjectRoleCheck bool `json:"projectRoleCheck"` + HasProjectCheck bool `json:"hasProjectCheck"` + PrivateLabelingSetting string `json:"privateLabelingSetting"` +} + +func CreateProject(apiURL, pat, domain string) (string, error) { + createProject := &createProject{ + Name: "SAML", + ProjectRoleAssertion: false, + ProjectRoleCheck: false, + HasProjectCheck: false, + PrivateLabelingSetting: "PRIVATE_LABELING_SETTING_UNSPECIFIED", + } + resp, err := doRequestWithHeaders(apiURL+"/management/v1/projects", pat, domain, createProject) + if err != nil { + return "", err + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + defer resp.Body.Close() + + p := new(project) + if err := json.Unmarshal(data, p); err != nil { + return "", err + } + return p.ID, nil +} + +type createApp struct { + Name string `json:"name"` + MetadataXml string `json:"metadataXml"` + LoginVersion version `json:"loginVersion"` +} +type version struct { + LoginV2 loginV2 `json:"loginV2"` +} +type loginV2 struct { + BaseUri string `json:"baseUri"` +} + +func CreateApp(apiURL, pat, domain, projectID string, spMetadata []byte, loginURL string) error { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(spMetadata))) + base64.URLEncoding.Encode(encoded, spMetadata) + + createApp := &createApp{ + Name: "SAML", + MetadataXml: string(encoded), + LoginVersion: version{ + LoginV2: loginV2{ + BaseUri: loginURL, + }, + }, + } + _, err := doRequestWithHeaders(apiURL+"/management/v1/projects/"+projectID+"/apps/saml", pat, domain, createApp) + if err != nil { + return fmt.Errorf("error creating saml app with request %+v: %v", *createApp, err) + } + return err +} + +func doRequestWithHeaders(apiURL, pat, domain string, body any) (*http.Response, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequest(http.MethodPost, apiURL, io.NopCloser(bytes.NewReader(data))) + if err != nil { + return nil, err + } + values := http.Header{} + values.Add("Authorization", "Bearer "+pat) + values.Add("x-forwarded-host", domain) + values.Add("Content-Type", "application/json") + req.Header = values + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + return resp, nil +} diff --git a/login/apps/login-test-acceptance/setup/go.mod b/login/apps/login-test-acceptance/setup/go.mod new file mode 100644 index 0000000000..7be166ef9b --- /dev/null +++ b/login/apps/login-test-acceptance/setup/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/apps/login-test-acceptance/setup + +go 1.23.3 diff --git a/login/apps/login-test-acceptance/setup/go.sum b/login/apps/login-test-acceptance/setup/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/setup/main.go b/login/apps/login-test-acceptance/setup/main.go new file mode 100644 index 0000000000..38dd16da61 --- /dev/null +++ b/login/apps/login-test-acceptance/setup/main.go @@ -0,0 +1,3 @@ +package main + +func main() {} diff --git a/login/apps/login-test-acceptance/setup/setup.sh b/login/apps/login-test-acceptance/setup/setup.sh new file mode 100755 index 0000000000..9d1a04e18f --- /dev/null +++ b/login/apps/login-test-acceptance/setup/setup.sh @@ -0,0 +1,139 @@ +#!/bin/sh + +set -e pipefail + +PAT_FILE=${PAT_FILE:-./pat/zitadel-admin-sa.pat} +LOGIN_BASE_URL=${LOGIN_BASE_URL:-"http://localhost:3000"} +ZITADEL_API_PROTOCOL="${ZITADEL_API_PROTOCOL:-http}" +ZITADEL_API_DOMAIN="${ZITADEL_API_DOMAIN:-localhost}" +ZITADEL_API_PORT="${ZITADEL_API_PORT:-8080}" +ZITADEL_API_URL="${ZITADEL_API_URL:-${ZITADEL_API_PROTOCOL}://${ZITADEL_API_DOMAIN}:${ZITADEL_API_PORT}}" +ZITADEL_API_INTERNAL_URL="${ZITADEL_API_INTERNAL_URL:-${ZITADEL_API_URL}}" +SINK_EMAIL_INTERNAL_URL="${SINK_EMAIL_INTERNAL_URL:-"http://sink:3333/email"}" +SINK_SMS_INTERNAL_URL="${SINK_SMS_INTERNAL_URL:-"http://sink:3333/sms"}" +SINK_NOTIFICATION_URL="${SINK_NOTIFICATION_URL:-"http://localhost:3333/notification"}" +WRITE_ENVIRONMENT_FILE=${WRITE_ENVIRONMENT_FILE:-$(dirname "$0")/../apps/login/.env.test.local} + +if [ -z "${PAT}" ]; then + echo "Reading PAT from file ${PAT_FILE}" + PAT=$(cat ${PAT_FILE}) +fi + +################################################################# +# ServiceAccount as Login Client +################################################################# + +SERVICEACCOUNT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/machine" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userName\": \"login\", \"name\": \"Login v2\", \"description\": \"Serviceaccount for Login v2\", \"accessTokenType\": \"ACCESS_TOKEN_TYPE_BEARER\"}") +echo "Received ServiceAccount response: ${SERVICEACCOUNT_RESPONSE}" + +SERVICEACCOUNT_ID=$(echo ${SERVICEACCOUNT_RESPONSE} | jq -r '. | .userId') +echo "Received ServiceAccount ID: ${SERVICEACCOUNT_ID}" + +MEMBER_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/members" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"userId\": \"${SERVICEACCOUNT_ID}\", \"roles\": [\"IAM_LOGIN_CLIENT\"]}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/management/v1/users/${SERVICEACCOUNT_ID}/pats" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"expirationDate\": \"2519-04-01T08:45:00.000000Z\"}") +echo "Received Member response: ${MEMBER_RESPONSE}" + +SA_PAT=$(echo ${SA_PAT_RESPONSE} | jq -r '. | .token') +echo "Received ServiceAccount Token: ${SA_PAT}" + +################################################################# +# Environment files +################################################################# + +echo "Writing environment file ${WRITE_ENVIRONMENT_FILE}." + +echo "ZITADEL_API_URL=${ZITADEL_API_URL} +ZITADEL_SERVICE_USER_TOKEN=${SA_PAT} +ZITADEL_ADMIN_TOKEN=${PAT} +SINK_NOTIFICATION_URL=${SINK_NOTIFICATION_URL} +EMAIL_VERIFICATION=true +DEBUG=false +LOGIN_BASE_URL=${LOGIN_BASE_URL} +NODE_TLS_REJECT_UNAUTHORIZED=0 +ZITADEL_ADMIN_USER=${ZITADEL_ADMIN_USER:-"zitadel-admin@zitadel.localhost"} +NEXT_PUBLIC_BASE_PATH=/ui/v2/login +" > ${WRITE_ENVIRONMENT_FILE} + +echo "Wrote environment file ${WRITE_ENVIRONMENT_FILE}" +cat ${WRITE_ENVIRONMENT_FILE} + +################################################################# +# SMS provider with HTTP +################################################################# + +SMSHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_SMS_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received SMS HTTP response: ${SMSHTTP_RESPONSE}" + +SMSHTTP_ID=$(echo ${SMSHTTP_RESPONSE} | jq -r '. | .id') +echo "Received SMS HTTP ID: ${SMSHTTP_ID}" + +SMS_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/sms/${SMSHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received SMS active response: ${SMS_ACTIVE_RESPONSE}" + +################################################################# +# Email provider with HTTP +################################################################# + +EMAILHTTP_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/http" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"endpoint\": \"${SINK_EMAIL_INTERNAL_URL}\", \"description\": \"test\"}") +echo "Received Email HTTP response: ${EMAILHTTP_RESPONSE}" + +EMAILHTTP_ID=$(echo ${EMAILHTTP_RESPONSE} | jq -r '. | .id') +echo "Received Email HTTP ID: ${EMAILHTTP_ID}" + +EMAIL_ACTIVE_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/admin/v1/email/${EMAILHTTP_ID}/_activate" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json") +echo "Received Email active response: ${EMAIL_ACTIVE_RESPONSE}" + +################################################################# +# Wait for projection of default organization in ZITADEL +################################################################# + +DEFAULTORG_RESPONSE_RESULTS=0 +# waiting for default organization +until [ ${DEFAULTORG_RESPONSE_RESULTS} -eq 1 ] +do + DEFAULTORG_RESPONSE=$(curl -s --request POST \ + --url "${ZITADEL_API_INTERNAL_URL}/v2/organizations/_search" \ + --header "Authorization: Bearer ${PAT}" \ + --header "Host: ${ZITADEL_API_DOMAIN}" \ + --header "Content-Type: application/json" \ + -d "{\"queries\": [{\"defaultQuery\":{}}]}" ) + echo "Received default organization response: ${DEFAULTORG_RESPONSE}" + DEFAULTORG_RESPONSE_RESULTS=$(echo $DEFAULTORG_RESPONSE | jq -r '.result | length') + echo "Received default organization response result: ${DEFAULTORG_RESPONSE_RESULTS}" +done + diff --git a/login/apps/login-test-acceptance/sink/go.mod b/login/apps/login-test-acceptance/sink/go.mod new file mode 100644 index 0000000000..1da7622b58 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/go.mod @@ -0,0 +1,3 @@ +module github.com/zitadel/typescript/acceptance/sink + +go 1.24.0 diff --git a/login/apps/login-test-acceptance/sink/go.sum b/login/apps/login-test-acceptance/sink/go.sum new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/sink/main.go b/login/apps/login-test-acceptance/sink/main.go new file mode 100644 index 0000000000..f3795ba0d0 --- /dev/null +++ b/login/apps/login-test-acceptance/sink/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "net/http" +) + +type serializableData struct { + ContextInfo map[string]interface{} `json:"contextInfo,omitempty"` + Args map[string]interface{} `json:"args,omitempty"` +} + +type response struct { + Recipient string `json:"recipient,omitempty"` +} + +func main() { + port := flag.String("port", "3333", "used port for the sink") + email := flag.String("email", "/email", "path for a sent email") + emailKey := flag.String("email-key", "recipientEmailAddress", "value in the sent context info of the email used as key to retrieve the notification") + sms := flag.String("sms", "/sms", "path for a sent sms") + smsKey := flag.String("sms-key", "recipientPhoneNumber", "value in the sent context info of the sms used as key to retrieve the notification") + notification := flag.String("notification", "/notification", "path to receive the notification") + flag.Parse() + + messages := make(map[string]serializableData) + + http.HandleFunc(*email, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + email, ok := serializableData.ContextInfo[*emailKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(email + ": " + string(data)) + messages[email] = serializableData + io.WriteString(w, "Email!\n") + }) + + http.HandleFunc(*sms, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + serializableData := serializableData{} + if err := json.Unmarshal(data, &serializableData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + phone, ok := serializableData.ContextInfo[*smsKey].(string) + if !ok { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + fmt.Println(phone + ": " + string(data)) + messages[phone] = serializableData + io.WriteString(w, "SMS!\n") + }) + + http.HandleFunc(*notification, func(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + response := response{} + if err := json.Unmarshal(data, &response); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + msg, ok := messages[response.Recipient] + if !ok { + http.Error(w, "No messages found for recipient: "+response.Recipient, http.StatusNotFound) + return + } + serializableData, err := json.Marshal(msg) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, string(serializableData)) + }) + + fmt.Println("Starting server on", *port) + fmt.Println(*email, " for email handling") + fmt.Println(*sms, " for sms handling") + fmt.Println(*notification, " for retrieving notifications") + http.Handle("/healthy", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return })) + fmt.Println("/healthy returns 200 OK") + err := http.ListenAndServe(":"+*port, nil) + if err != nil { + panic("Server could not be started: " + err.Error()) + } +} diff --git a/login/apps/login-test-acceptance/test-results/.gitignore b/login/apps/login-test-acceptance/test-results/.gitignore new file mode 100644 index 0000000000..377ccd3fdf --- /dev/null +++ b/login/apps/login-test-acceptance/test-results/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/login/apps/login-test-acceptance/test-results/.gitkeep b/login/apps/login-test-acceptance/test-results/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/login/apps/login-test-acceptance/tests/admin.spec.ts b/login/apps/login-test-acceptance/tests/admin.spec.ts new file mode 100644 index 0000000000..13b748fc63 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/admin.spec.ts @@ -0,0 +1,7 @@ +import { test } from "@playwright/test"; +import { loginScreenExpect, loginWithPassword } from "./login"; + +test("admin login", async ({ page }) => { + await loginWithPassword(page, process.env["ZITADEL_ADMIN_USER"], "Password1!"); + await loginScreenExpect(page, "ZITADEL Admin"); +}); diff --git a/login/apps/login-test-acceptance/tests/code-screen.ts b/login/apps/login-test-acceptance/tests/code-screen.ts new file mode 100644 index 0000000000..3ab9dad26d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function codeScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function codeScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify OTP code"); +} diff --git a/login/apps/login-test-acceptance/tests/code.ts b/login/apps/login-test-acceptance/tests/code.ts new file mode 100644 index 0000000000..e27d1f6150 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/code.ts @@ -0,0 +1,17 @@ +import { Page } from "@playwright/test"; +import { codeScreen } from "./code-screen"; +import { getOtpFromSink } from "./sink"; + +export async function otpFromSink(page: Page, key: string) { + const c = await getOtpFromSink(key); + await code(page, c); +} + +export async function code(page: Page, code: string) { + await codeScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function codeResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify-screen.ts b/login/apps/login-test-acceptance/tests/email-verify-screen.ts new file mode 100644 index 0000000000..b077ecb424 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const codeTextInput = "code-text-input"; + +export async function emailVerifyScreen(page: Page, code: string) { + await page.getByTestId(codeTextInput).pressSequentially(code); +} + +export async function emailVerifyScreenExpect(page: Page, code: string) { + await expect(page.getByTestId(codeTextInput)).toHaveValue(code); + await expect(page.getByTestId("error").locator("div")).toContainText("Could not verify email"); +} diff --git a/login/apps/login-test-acceptance/tests/email-verify.spec.ts b/login/apps/login-test-acceptance/tests/email-verify.spec.ts new file mode 100644 index 0000000000..2c546b8eee --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.spec.ts @@ -0,0 +1,69 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { emailVerify, emailVerifyResend } from "./email-verify"; +import { emailVerifyScreenExpect } from "./email-verify-screen"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { getCodeFromSink } from "./sink"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: false, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("user email not verified, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerify(page, c); + // wait for resend of the code + await page.waitForTimeout(2000); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, verify", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + await emailVerifyResend(page); + const c = await getCodeFromSink(user.getUsername()); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await loginScreenExpect(page, user.getFullName()); +}); + +test("user email not verified, resend, old code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + const c = await getCodeFromSink(user.getUsername()); + await emailVerifyResend(page); + // wait for resend of the code + await page.waitForTimeout(2000); + await emailVerify(page, c); + await emailVerifyScreenExpect(page, c); +}); + +test("user email not verified, wrong code", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + // auto-redirect on /verify + const code = "wrong"; + await emailVerify(page, code); + await emailVerifyScreenExpect(page, code); +}); diff --git a/login/apps/login-test-acceptance/tests/email-verify.ts b/login/apps/login-test-acceptance/tests/email-verify.ts new file mode 100644 index 0000000000..5275e82bfe --- /dev/null +++ b/login/apps/login-test-acceptance/tests/email-verify.ts @@ -0,0 +1,15 @@ +import { Page } from "@playwright/test"; +import { emailVerifyScreen } from "./email-verify-screen"; + +export async function startEmailVerify(page: Page, loginname: string) { + await page.goto("./verify"); +} + +export async function emailVerify(page: Page, code: string) { + await emailVerifyScreen(page, code); + await page.getByTestId("submit-button").click(); +} + +export async function emailVerifyResend(page: Page) { + await page.getByTestId("resend-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/idp-apple.spec.ts b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts new file mode 100644 index 0000000000..32d3adba6b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-apple.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Apple doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Apple IDP", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User authenticates with Apple + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Apple IDP - error", async ({ page }) => { + test.skip(); + // Given an Apple IDP is configured on the organization + // Given the user has an Apple added as auth method + // User is redirected to Apple + // User authenticates with Apple and gets an error + // User is redirect back to login + // An error is shown to the user "Something went wrong in Apple Login" +}); + +test("login with Apple IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Apple + // User authenticates in Apple + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Apple IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Apple IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Apple IDP, no user linked, user link successful", async ({ page }) => { + test.skip(); + // Given idp Apple is configure on the organization as only authencation method + // Given idp Apple is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Apple + // User authenticates in Apple with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts new file mode 100644 index 0000000000..d68475a226 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-jwt.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic JWT IDP", async ({ page }) => { + test.skip(); + // Given a Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User authenticates with the Generic JWT IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic JWT IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic JWT IDP is configured on the organization + // Given the user has Generic JWT IDP added as auth method + // User is redirected to the Generic JWT IDP + // User authenticates with the Generic JWT IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic JWT IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic JWT IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic JWT IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic JWT IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic JWT is configure on the organization as only authencation method + // Given idp Generic JWT is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic JWT + // User authenticates in Generic JWT with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts new file mode 100644 index 0000000000..24c25d0005 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oauth.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Generic OAuth IDP", async ({ page }) => { + test.skip(); + // Given a Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User authenticates with the Generic OAuth IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OAuth IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OAuth IDP is configured on the organization + // Given the user has Generic OAuth IDP added as auth method + // User is redirected to the Generic OAuth IDP + // User authenticates with the Generic OAuth IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OAuth IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OAuth IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OAuth IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OAuth IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OAuth is configure on the organization as only authencation method + // Given idp Generic OAuth is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OAuth + // User authenticates in Generic OAuth with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts new file mode 100644 index 0000000000..391481f99d --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-generic-oidc.spec.ts @@ -0,0 +1,101 @@ +// Note, we should use a provider such as Google to test this, where we know OIDC standard is properly implemented + +import test from "@playwright/test"; + +test("login with Generic OIDC IDP", async ({ page }) => { + test.skip(); + // Given a Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User authenticates with the Generic OIDC IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Generic OIDC IDP - error", async ({ page }) => { + test.skip(); + // Given the Generic OIDC IDP is configured on the organization + // Given the user has Generic OIDC IDP added as auth method + // User is redirected to the Generic OIDC IDP + // User authenticates with the Generic OIDC IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Generic OIDC IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Generic OIDC IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Generic OIDC IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Generic OIDC IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Generic OIDC is configure on the organization as only authencation method + // Given idp Generic OIDC is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Generic OIDC + // User authenticates in Generic OIDC with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts new file mode 100644 index 0000000000..2c39092851 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github-enterprise.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub Enterprise IDP", async ({ page }) => { + test.skip(); + // Given a GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User authenticates with the GitHub Enterprise IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub Enterprise IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub Enterprise IDP is configured on the organization + // Given the user has GitHub Enterprise IDP added as auth method + // User is redirected to the GitHub Enterprise IDP + // User authenticates with the GitHub Enterprise IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub Enterprise IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub Enterprise IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub Enterprise IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub Enterprise is configure on the organization as only authencation method + // Given idp GitHub Enterprise is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub Enterprise + // User authenticates in GitHub Enterprise with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-github.spec.ts b/login/apps/login-test-acceptance/tests/idp-github.spec.ts new file mode 100644 index 0000000000..689e040537 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-github.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitHub IDP", async ({ page }) => { + test.skip(); + // Given a GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User authenticates with the GitHub IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitHub IDP - error", async ({ page }) => { + test.skip(); + // Given the GitHub IDP is configured on the organization + // Given the user has GitHub IDP added as auth method + // User is redirected to the GitHub IDP + // User authenticates with the GitHub IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with GitHub IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to GitHub + // User authenticates in GitHub + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with GitHub IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with GitHub IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with GitHub IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp GitHub is configure on the organization as only authencation method + // Given idp GitHub is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to GitHub + // User authenticates in GitHub with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts new file mode 100644 index 0000000000..1b05d5e19b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab-self-hosted.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab Self-Hosted IDP", async ({ page }) => { + test.skip(); + // Given a GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User authenticates with the GitLab Self-Hosted IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab Self-Hosted IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab Self-Hosted IDP is configured on the organization + // Given the user has GitLab Self-Hosted IDP added as auth method + // User is redirected to the GitLab Self-Hosted IDP + // User authenticates with the GitLab Self-Hosted IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab Self-Hosted IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab Self-Hosted IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab Self-Hosted is configure on the organization as only authencation method + // Given idp Gitlab Self-Hosted is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab Self-Hosted + // User authenticates in Gitlab Self-Hosted with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts new file mode 100644 index 0000000000..fdb235843b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-gitlab.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with GitLab IDP", async ({ page }) => { + test.skip(); + // Given a GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User authenticates with the GitLab IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with GitLab IDP - error", async ({ page }) => { + test.skip(); + // Given the GitLab IDP is configured on the organization + // Given the user has GitLab IDP added as auth method + // User is redirected to the GitLab IDP + // User authenticates with the GitLab IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Gitlab IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Gitlab + // User authenticates in Gitlab + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Gitlab IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Gitlab IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Gitlab IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Gitlab is configure on the organization as only authencation method + // Given idp Gitlab is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Gitlab + // User authenticates in Gitlab with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-google.spec.ts b/login/apps/login-test-acceptance/tests/idp-google.spec.ts new file mode 100644 index 0000000000..8eb4d54e34 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-google.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with Google IDP", async ({ page }) => { + test.skip(); + // Given a Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User authenticates with the Google IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Google IDP - error", async ({ page }) => { + test.skip(); + // Given the Google IDP is configured on the organization + // Given the user has Google IDP added as auth method + // User is redirected to the Google IDP + // User authenticates with the Google IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Google IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Google + // User authenticates in Google + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Google IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Google IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Google IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Google is configure on the organization as only authencation method + // Given idp Google is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Google + // User authenticates in Google with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts new file mode 100644 index 0000000000..0705ed45f8 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-ldap.spec.ts @@ -0,0 +1,99 @@ +import test from "@playwright/test"; + +test("login with LDAP IDP", async ({ page }) => { + test.skip(); + // Given a LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User authenticates with the LDAP IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with LDAP IDP - error", async ({ page }) => { + test.skip(); + // Given the LDAP IDP is configured on the organization + // Given the user has LDAP IDP added as auth method + // User is redirected to the LDAP IDP + // User authenticates with the LDAP IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with LDAP IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to LDAP + // User authenticates in LDAP + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with LDAP IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with LDAP IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with LDAP IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp LDAP is configure on the organization as only authencation method + // Given idp LDAP is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to LDAP + // User authenticates in LDAP with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts new file mode 100644 index 0000000000..15d67c28aa --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-microsoft.spec.ts @@ -0,0 +1,102 @@ +// Note for all tests, in case Microsoft doesn't deliver all relevant information per default +// We should add an action in the needed cases + +import test from "@playwright/test"; + +test("login with Microsoft IDP", async ({ page }) => { + test.skip(); + // Given a Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User authenticates with the Microsoft IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with Microsoft IDP - error", async ({ page }) => { + test.skip(); + // Given the Microsoft IDP is configured on the organization + // Given the user has Microsoft IDP added as auth method + // User is redirected to the Microsoft IDP + // User authenticates with the Microsoft IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with Microsoft IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to Microsoft + // User authenticates in Microsoft + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with Microsoft IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with Microsoft IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking not allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with Microsoft IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp Microsoft is configure on the organization as only authencation method + // Given idp Microsoft is configure with manually account linking allowed, and linking set to existing email + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to Microsoft + // User authenticates in Microsoft with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/idp-saml.spec.ts b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts new file mode 100644 index 0000000000..90d8d618b4 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/idp-saml.spec.ts @@ -0,0 +1,103 @@ +import test from "@playwright/test"; + +test("login with SAML IDP", async ({ page }) => { + test.skip(); + // Given a SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User authenticates with the SAML IDP + // User is redirected back to login + // User is redirected to the app +}); + +test("login with SAML IDP - error", async ({ page }) => { + test.skip(); + // Given the SAML IDP is configured on the organization + // Given the user has SAML IDP added as auth method + // User is redirected to the SAML IDP + // User authenticates with the SAML IDP and gets an error + // User is redirected back to login + // An error is shown to the user "Something went wrong" +}); + +test("login with SAML IDP, no user existing - auto register", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given ZITADEL Action is added to autofill missing user information + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation alloweed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // User will see the registration page with pre filled user information + // User fills missing information + // User clicks register button + // User is created in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user existing - auto register enabled - manual creation disabled, creation not possible", async ({ + page, +}) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account creation not allowed, and automatic creation enabled + // Given no user exists yet + // User is automatically redirected to SAML + // User authenticates in SAML + // User is redirect to ZITADEL login + // Because of missing informaiton on the user auto creation is not possible + // Error message is shown, that registration of the user was not possible due to missing information +}); + +test("login with SAML IDP, no user linked - auto link", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User is linked with existing user in ZITADEL + // User is redirected to the app (default redirect url) +}); + +test("login with SAML IDP, no user linked, linking not possible", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking not allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User will get an error message that account linking wasn't possible +}); + +test("login with SAML IDP, no user linked, linking successful", async ({ page }) => { + test.skip(); + // Given idp SAML is configure on the organization as only authencation method + // Given idp SAML is configure with manually account linking allowed, and linking set to existing email + // Given ZITADEL Action is added to autofill missing user information + // Given user with email address user@zitadel.com doesn't exists + // User is automatically redirected to SAML + // User authenticates in SAML with user@zitadel.com + // User is redirect to ZITADEL login + // User with email address user@zitadel.com can not be found + // User is prompted to link the account manually + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts new file mode 100644 index 0000000000..cc58dbcc71 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login-configuration-possiblities.spec.ts @@ -0,0 +1,57 @@ +import test from "@playwright/test"; + +test("login with mfa setup, mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has enabled at least one mfa types + // Given the user has a password but no mfa registered + // User authenticates with login name and password + // User is prompted to setup a mfa, mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, no mfa setup prompt", async ({ page }) => { + test.skip(); + // Given the organization has set "multifactor init check time" to 0 + // Given the organization has enabled mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // user is directly loged in and not prompted to setup mfa +}); + +test("login with mfa setup, force mfa for local authenticated users", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa for local authentiacted users + // Given the organization has enabled all possible mfa types + // Given the user has a password but no mfa registered + // User authenticates with loginname and password + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - external user", async ({ page }) => { + test.skip(); + // Given the organization has enabled force mfa + // Given the organization has enabled all possible mfa types + // Given the user has an idp but no mfa registered + // enter login name + // redirect to configured external idp + // User is prompted to setup a mfa, all possible mfa providers are listed, the user can choose the provider +}); + +test("login with mfa setup, force mfa - local user, wrong password", async ({ page }) => { + test.skip(); + // Given the organization has a password lockout policy set to 1 on the max password attempts + // Given the user has only a password as auth methos + // enter login name + // enter wrong password + // User will get an error "Wrong password" + // enter password + // User will get an error "Max password attempts reached - user is locked. Please reach out to your administrator" +}); diff --git a/login/apps/login-test-acceptance/tests/login.ts b/login/apps/login-test-acceptance/tests/login.ts new file mode 100644 index 0000000000..2076412456 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/login.ts @@ -0,0 +1,41 @@ +import { expect, Page } from "@playwright/test"; +import { code, otpFromSink } from "./code"; +import { loginname } from "./loginname"; +import { password } from "./password"; +import { totp } from "./zitadel"; + +export async function startLogin(page: Page) { + await page.goto(`./loginname`); +} + +export async function loginWithPassword(page: Page, username: string, pw: string) { + await startLogin(page); + await loginname(page, username); + await password(page, pw); +} + +export async function loginWithPasskey(page: Page, authenticatorId: string, username: string) { + await startLogin(page); + await loginname(page, username); + // await passkey(page, authenticatorId); +} + +export async function loginScreenExpect(page: Page, fullName: string) { + await expect(page).toHaveURL(/.*signedin.*/); + await expect(page.getByRole("heading")).toContainText(fullName); +} + +export async function loginWithPasswordAndEmailOTP(page: Page, username: string, password: string, email: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, email); +} + +export async function loginWithPasswordAndPhoneOTP(page: Page, username: string, password: string, phone: string) { + await loginWithPassword(page, username, password); + await otpFromSink(page, phone); +} + +export async function loginWithPasswordAndTOTP(page: Page, username: string, password: string, secret: string) { + await loginWithPassword(page, username, password); + await code(page, totp(secret)); +} diff --git a/login/apps/login-test-acceptance/tests/loginname-screen.ts b/login/apps/login-test-acceptance/tests/loginname-screen.ts new file mode 100644 index 0000000000..be41a28eda --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname-screen.ts @@ -0,0 +1,12 @@ +import { expect, Page } from "@playwright/test"; + +const usernameTextInput = "username-text-input"; + +export async function loginnameScreen(page: Page, username: string) { + await page.getByTestId(usernameTextInput).pressSequentially(username); +} + +export async function loginnameScreenExpect(page: Page, username: string) { + await expect(page.getByTestId(usernameTextInput)).toHaveValue(username); + await expect(page.getByTestId("error").locator("div")).toContainText("User not found in the system"); +} diff --git a/login/apps/login-test-acceptance/tests/loginname.ts b/login/apps/login-test-acceptance/tests/loginname.ts new file mode 100644 index 0000000000..2050ec1d3c --- /dev/null +++ b/login/apps/login-test-acceptance/tests/loginname.ts @@ -0,0 +1,7 @@ +import { Page } from "@playwright/test"; +import { loginnameScreen } from "./loginname-screen"; + +export async function loginname(page: Page, username: string) { + await loginnameScreen(page, username); + await page.getByTestId("submit-button").click(); +} diff --git a/login/apps/login-test-acceptance/tests/passkey.ts b/login/apps/login-test-acceptance/tests/passkey.ts new file mode 100644 index 0000000000..d8cda10ddb --- /dev/null +++ b/login/apps/login-test-acceptance/tests/passkey.ts @@ -0,0 +1,109 @@ +import { expect, Page } from "@playwright/test"; +import { CDPSession } from "playwright-core"; + +interface session { + client: CDPSession; + authenticatorId: string; +} + +async function client(page: Page): Promise { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + return { client: cdpSession, authenticatorId: result.authenticatorId }; +} + +export async function passkeyRegister(page: Page): Promise { + const session = await client(page); + + await passkeyNotExisting(session.client, session.authenticatorId); + await simulateSuccessfulPasskeyRegister(session.client, session.authenticatorId, () => + page.getByTestId("submit-button").click(), + ); + await passkeyRegistered(session.client, session.authenticatorId); + + return session.authenticatorId; +} + +export async function passkey(page: Page, authenticatorId: string) { + const cdpSession = await page.context().newCDPSession(page); + await cdpSession.send("WebAuthn.enable", { enableUI: false }); + + const signCount = await passkeyExisting(cdpSession, authenticatorId); + + await simulateSuccessfulPasskeyInput(cdpSession, authenticatorId, () => page.getByTestId("submit-button").click()); + + await passkeyUsed(cdpSession, authenticatorId, signCount); +} + +async function passkeyNotExisting(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(0); +} + +async function passkeyRegistered(client: CDPSession, authenticatorId: string) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + await passkeyUsed(client, authenticatorId, 0); +} + +async function passkeyExisting(client: CDPSession, authenticatorId: string): Promise { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + return result.credentials[0].signCount; +} + +async function passkeyUsed(client: CDPSession, authenticatorId: string, signCount: number) { + const result = await client.send("WebAuthn.getCredentials", { authenticatorId }); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0].signCount).toBeGreaterThan(signCount); +} + +async function simulateSuccessfulPasskeyRegister( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAdded", () => { + console.log("Credential Added!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} + +async function simulateSuccessfulPasskeyInput( + client: CDPSession, + authenticatorId: string, + operationTrigger: () => Promise, +) { + // initialize event listeners to wait for a successful passkey input event + const operationCompleted = new Promise((resolve) => { + client.on("WebAuthn.credentialAsserted", () => { + console.log("Credential Asserted!"); + resolve(); + }); + }); + + // perform a user action that triggers passkey prompt + await operationTrigger(); + + // wait to receive the event that the passkey was successfully registered or verified + await operationCompleted; +} diff --git a/login/apps/login-test-acceptance/tests/password-screen.ts b/login/apps/login-test-acceptance/tests/password-screen.ts new file mode 100644 index 0000000000..fda6f6d39f --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password-screen.ts @@ -0,0 +1,98 @@ +import { expect, Page } from "@playwright/test"; +import { getCodeFromSink } from "./sink"; + +const codeField = "code-text-input"; +const passwordField = "password-text-input"; +const passwordChangeField = "password-change-text-input"; +const passwordChangeConfirmField = "password-change-confirm-text-input"; +const passwordSetField = "password-set-text-input"; +const passwordSetConfirmField = "password-set-confirm-text-input"; +const lengthCheck = "length-check"; +const symbolCheck = "symbol-check"; +const numberCheck = "number-check"; +const uppercaseCheck = "uppercase-check"; +const lowercaseCheck = "lowercase-check"; +const equalCheck = "equal-check"; + +const matchText = "Matches"; +const noMatchText = "Doesn't match"; + +export async function changePasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordChangeField).pressSequentially(password1); + await page.getByTestId(passwordChangeConfirmField).pressSequentially(password2); +} + +export async function passwordScreen(page: Page, password: string) { + await page.getByTestId(passwordField).pressSequentially(password); +} + +export async function passwordScreenExpect(page: Page, password: string) { + await expect(page.getByTestId(passwordField)).toHaveValue(password); + await expect(page.getByTestId("error").locator("div")).toContainText("Failed to authenticate."); +} + +export async function changePasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordChangeField)).toHaveValue(password1); + await expect(page.getByTestId(passwordChangeConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} + +async function checkComplexity( + page: Page, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await checkContent(page, lengthCheck, length); + await checkContent(page, symbolCheck, symbol); + await checkContent(page, numberCheck, number); + await checkContent(page, uppercaseCheck, uppercase); + await checkContent(page, lowercaseCheck, lowercase); + await checkContent(page, equalCheck, equals); +} + +async function checkContent(page: Page, testid: string, match: boolean) { + if (match) { + await expect(page.getByTestId(testid)).toContainText(matchText); + } else { + await expect(page.getByTestId(testid)).toContainText(noMatchText); + } +} + +export async function resetPasswordScreen(page: Page, username: string, password1: string, password2: string) { + const c = await getCodeFromSink(username); + await page.getByTestId(codeField).pressSequentially(c); + await page.getByTestId(passwordSetField).pressSequentially(password1); + await page.getByTestId(passwordSetConfirmField).pressSequentially(password2); +} + +export async function resetPasswordScreenExpect( + page: Page, + password1: string, + password2: string, + length: boolean, + symbol: boolean, + number: boolean, + uppercase: boolean, + lowercase: boolean, + equals: boolean, +) { + await expect(page.getByTestId(passwordSetField)).toHaveValue(password1); + await expect(page.getByTestId(passwordSetConfirmField)).toHaveValue(password2); + + await checkComplexity(page, length, symbol, number, uppercase, lowercase, equals); +} diff --git a/login/apps/login-test-acceptance/tests/password.ts b/login/apps/login-test-acceptance/tests/password.ts new file mode 100644 index 0000000000..ccf3e509d9 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/password.ts @@ -0,0 +1,29 @@ +import { Page } from "@playwright/test"; +import { changePasswordScreen, passwordScreen, resetPasswordScreen } from "./password-screen"; + +const passwordSubmitButton = "submit-button"; +const passwordResetButton = "reset-button"; + +export async function startChangePassword(page: Page, loginname: string) { + await page.goto("./password/change?" + new URLSearchParams({ loginName: loginname })); +} + +export async function changePassword(page: Page, password: string) { + await changePasswordScreen(page, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function password(page: Page, password: string) { + await passwordScreen(page, password); + await page.getByTestId(passwordSubmitButton).click(); +} + +export async function startResetPassword(page: Page) { + await page.getByTestId(passwordResetButton).click(); +} + +export async function resetPassword(page: Page, username: string, password: string) { + await startResetPassword(page); + await resetPasswordScreen(page, username, password, password); + await page.getByTestId(passwordSubmitButton).click(); +} diff --git a/login/apps/login-test-acceptance/tests/register-screen.ts b/login/apps/login-test-acceptance/tests/register-screen.ts new file mode 100644 index 0000000000..d14f5dc970 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register-screen.ts @@ -0,0 +1,27 @@ +import { Page } from "@playwright/test"; + +const passwordField = "password-text-input"; +const passwordConfirmField = "password-confirm-text-input"; + +export async function registerUserScreenPassword(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("password-radio").click(); +} + +export async function registerUserScreenPasskey(page: Page, firstname: string, lastname: string, email: string) { + await registerUserScreen(page, firstname, lastname, email); + await page.getByTestId("passkey-radio").click(); +} + +export async function registerPasswordScreen(page: Page, password1: string, password2: string) { + await page.getByTestId(passwordField).pressSequentially(password1); + await page.getByTestId(passwordConfirmField).pressSequentially(password2); +} + +export async function registerUserScreen(page: Page, firstname: string, lastname: string, email: string) { + await page.getByTestId("firstname-text-input").pressSequentially(firstname); + await page.getByTestId("lastname-text-input").pressSequentially(lastname); + await page.getByTestId("email-text-input").pressSequentially(email); + await page.getByTestId("privacy-policy-checkbox").check(); + await page.getByTestId("tos-checkbox").check(); +} diff --git a/login/apps/login-test-acceptance/tests/register.spec.ts b/login/apps/login-test-acceptance/tests/register.spec.ts new file mode 100644 index 0000000000..4ad7e9e349 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.spec.ts @@ -0,0 +1,183 @@ +import { faker } from "@faker-js/faker"; +import { test } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect } from "./login"; +import { registerWithPasskey, registerWithPassword } from "./register"; +import { removeUserByUsername } from "./zitadel"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +test("register with password", async ({ page }) => { + const username = faker.internet.email(); + const password = "Password1!"; + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPassword(page, firstname, lastname, username, password, password); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with passkey", async ({ page }) => { + const username = faker.internet.email(); + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + + await registerWithPasskey(page, firstname, lastname, username); + await loginScreenExpect(page, firstname + " " + lastname); + + // wait for projection of user + await page.waitForTimeout(10000); + await removeUserByUsername(username); +}); + +test("register with username and password - only password enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and "password" + // User is redirected to app (default redirect url) +}); + +test("register with username and password - wrong password not enough characters", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password thats to short + // Error is shown "Password doesn't match the policy - it must have at least 8 characters" +}); + +test("register with username and password - wrong password number missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without a number + // Error is shown "Password doesn't match the policy - number missing" +}); + +test("register with username and password - wrong password upper case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an upper case + // Error is shown "Password doesn't match the policy - uppercase letter missing" +}); + +test("register with username and password - wrong password lower case missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an lower case + // Error is shown "Password doesn't match the policy - lowercase letter missing" +}); + +test("register with username and password - wrong password symboo missing", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is not enabled + // Given password policy is set to 8 characters and must include number, symbol, lower and upper letter + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // Only password is shown as an option - no passkey + // User enters "firstname", "lastname", "username" and a password without an symbol + // Error is shown "Password doesn't match the policy - symbol missing" +}); + +test("register with username and password - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks password + // User enters password + // User is redirected to app (default redirect url) +}); + +test("register with username and passkey - password and passkey enabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given on the default organization passkey is enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration page + // User enters "firstname", "lastname", "username" + // Password and passkey are shown as authentication option + // User clicks passkey + // Passkey is opened automatically + // User verifies passkey + // User is redirected to app (default redirect url) +}); + +test("register with username and password - registration disabled", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization no idp is configured and enabled + // Given user doesn't exist + // Button "register new user" is not available +}); + +test("register with username and password - multiple registration options", async ({ page }) => { + test.skip(); + // Given on the default organization "username and password is allowed" is enabled + // Given on the default organization "username registeration allowed" is enabled + // Given on the default organization one idp is configured and enabled + // Given user doesn't exist + // Click on button "register new user" + // User is redirected to registration options + // Local User and idp button are shown + // User clicks idp button + // User enters "firstname", "lastname", "username" and "password" + // User clicks next + // User is redirected to app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/register.ts b/login/apps/login-test-acceptance/tests/register.ts new file mode 100644 index 0000000000..164a72753b --- /dev/null +++ b/login/apps/login-test-acceptance/tests/register.ts @@ -0,0 +1,39 @@ +import { Page } from "@playwright/test"; +import { emailVerify } from "./email-verify"; +import { passkeyRegister } from "./passkey"; +import { registerPasswordScreen, registerUserScreenPasskey, registerUserScreenPassword } from "./register-screen"; +import { getCodeFromSink } from "./sink"; + +export async function registerWithPassword( + page: Page, + firstname: string, + lastname: string, + email: string, + password1: string, + password2: string, +) { + await page.goto("./register"); + await registerUserScreenPassword(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + await registerPasswordScreen(page, password1, password2); + await page.getByTestId("submit-button").click(); + await verifyEmail(page, email); +} + +export async function registerWithPasskey(page: Page, firstname: string, lastname: string, email: string): Promise { + await page.goto("./register"); + await registerUserScreenPasskey(page, firstname, lastname, email); + await page.getByTestId("submit-button").click(); + + // wait for projection of user + await page.waitForTimeout(10000); + const authId = await passkeyRegister(page); + + await verifyEmail(page, email); + return authId; +} + +async function verifyEmail(page: Page, email: string) { + const c = await getCodeFromSink(email); + await emailVerify(page, c); +} diff --git a/login/apps/login-test-acceptance/tests/select-account.ts b/login/apps/login-test-acceptance/tests/select-account.ts new file mode 100644 index 0000000000..64bd7cd145 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/select-account.ts @@ -0,0 +1,5 @@ +import { Page } from "@playwright/test"; + +export async function selectNewAccount(page: Page) { + await page.getByRole("link", { name: "Add another account" }).click(); +} diff --git a/login/apps/login-test-acceptance/tests/sink.ts b/login/apps/login-test-acceptance/tests/sink.ts new file mode 100644 index 0000000000..bc3336b358 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/sink.ts @@ -0,0 +1,43 @@ +import { Gaxios, GaxiosResponse } from "gaxios"; + +const awaitNotification = new Gaxios({ + url: process.env.SINK_NOTIFICATION_URL, + method: "POST", + retryConfig: { + httpMethodsToRetry: ["POST"], + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying request to sink notification service: ${error.message}`); + }, + }, +}); + +export async function getOtpFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const otp = response?.data?.args?.otp; + if (!otp) { + throw new Error(`Response does not contain an otp property: ${JSON.stringify(response.data, null, 2)}`); + } + return otp; + }); +} + +export async function getCodeFromSink(recipient: string): Promise { + return awaitNotification.request({ data: { recipient } }).then((response) => { + expectSuccess(response); + const code = response?.data?.args?.code; + if (!code) { + throw new Error(`Response does not contain a code property: ${JSON.stringify(response.data, null, 2)}`); + } + return code; + }); +} + +function expectSuccess(response: GaxiosResponse): void { + if (response.status !== 200) { + throw new Error(`Expected HTTP status 200, but got: ${response.status} - ${response.statusText}`); + } +} diff --git a/login/apps/login-test-acceptance/tests/user.ts b/login/apps/login-test-acceptance/tests/user.ts new file mode 100644 index 0000000000..3b03291408 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/user.ts @@ -0,0 +1,177 @@ +import { Page } from "@playwright/test"; +import { registerWithPasskey } from "./register"; +import { activateOTP, addTOTP, addUser, eventualNewUser, getUserByUsername, removeUser } from "./zitadel"; + +export interface userProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; +} + +class User { + private readonly props: userProps; + private user: string; + + constructor(userProps: userProps) { + this.props = userProps; + } + + async ensure(page: Page) { + const response = await addUser(this.props); + + this.setUserId(response.userId); + } + + async cleanup() { + await removeUser(this.getUserId()); + } + + public setUserId(userId: string) { + this.user = userId; + } + + public getUserId() { + return this.user; + } + + public getUsername() { + return this.props.email; + } + + public getPassword() { + return this.props.password; + } + + public getFirstname() { + return this.props.firstName; + } + + public getLastname() { + return this.props.lastName; + } + + public getPhone() { + return this.props.phone; + } + + public getFullName() { + return `${this.props.firstName} ${this.props.lastName}`; + } +} + +export class PasswordUser extends User { + async ensure(page: Page) { + await super.ensure(page); + await eventualNewUser(this.getUserId()); + } +} + +export enum OtpType { + sms = "sms", + email = "email", +} + +export interface otpUserProps { + email: string; + isEmailVerified?: boolean; + firstName: string; + lastName: string; + organization: string; + password: string; + passwordChangeRequired?: boolean; + phone: string; + isPhoneVerified?: boolean; + type: OtpType; +} + +export class PasswordUserWithOTP extends User { + private type: OtpType; + + constructor(props: otpUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: props.password, + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + passwordChangeRequired: props.passwordChangeRequired, + }); + this.type = props.type; + } + + async ensure(page: Page) { + await super.ensure(page); + await activateOTP(this.getUserId(), this.type); + await eventualNewUser(this.getUserId()); + } +} + +export class PasswordUserWithTOTP extends User { + private secret: string; + + async ensure(page: Page) { + await super.ensure(page); + this.secret = await addTOTP(this.getUserId()); + await eventualNewUser(this.getUserId()); + } + + public getSecret(): string { + return this.secret; + } +} + +export interface passkeyUserProps { + email: string; + firstName: string; + lastName: string; + organization: string; + phone: string; + isEmailVerified?: boolean; + isPhoneVerified?: boolean; +} + +export class PasskeyUser extends User { + private authenticatorId: string; + + constructor(props: passkeyUserProps) { + super({ + email: props.email, + firstName: props.firstName, + lastName: props.lastName, + organization: props.organization, + password: "", + phone: props.phone, + isEmailVerified: props.isEmailVerified, + isPhoneVerified: props.isPhoneVerified, + }); + } + + public async ensure(page: Page) { + const authId = await registerWithPasskey(page, this.getFirstname(), this.getLastname(), this.getUsername()); + this.authenticatorId = authId; + + // wait for projection of user + await page.waitForTimeout(10000); + } + + async cleanup() { + const resp: any = await getUserByUsername(this.getUsername()); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); + } + + public getAuthenticatorId(): string { + return this.authenticatorId; + } +} diff --git a/login/apps/login-test-acceptance/tests/username-passkey.spec.ts b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts new file mode 100644 index 0000000000..dff1c65f5a --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-passkey.spec.ts @@ -0,0 +1,43 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPasskey } from "./login"; +import { PasskeyUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasskeyUser }>({ + user: async ({ page }, use) => { + const user = new PasskeyUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and passkey login", async ({ user, page }) => { + await loginWithPasskey(page, user.getAuthenticatorId(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and passkey login, multiple auth methods", async ({ page }) => { + test.skip(); + // Given passkey and password is enabled on the organization of the user + // Given the user has password and passkey registered + // enter username + // passkey popup is directly shown + // user aborts passkey authentication + // user switches to password authentication + // user enters password + // user is redirected to app +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts new file mode 100644 index 0000000000..50605e5ff0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-change-required.spec.ts @@ -0,0 +1,41 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword } from "./password"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: true, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login, change required", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await page.waitForTimeout(10000); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts new file mode 100644 index 0000000000..dc29dc2286 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-changed.spec.ts @@ -0,0 +1,54 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword } from "./login"; +import { changePassword, startChangePassword } from "./password"; +import { changePasswordScreen, changePasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password changed login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + + // wait for projection of token + await page.waitForTimeout(10000); + + await startChangePassword(page, user.getUsername()); + await changePassword(page, changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password change not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await startChangePassword(page, user.getUsername()); + await changePasswordScreen(page, changedPw1, changedPw2); + await changePasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts new file mode 100644 index 0000000000..e4a77751c1 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_email.spec.ts @@ -0,0 +1,98 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code, codeResend, otpFromSink } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndEmailOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.email, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and email otp login, enter code manually", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndEmailOTP(page, user.getUsername(), user.getPassword(), user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, click link in email", async ({ page }) => { + base.skip(); + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks link in the email + // User is redirected to the app (default redirect url) +}); + +test.skip("DOESN'T WORK: username, password and email otp login, resend code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks resend code + // User receives a new email with a verification code + // User enters the new code in the ui + // User is redirected to the app (default redirect url) + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await codeResend(page); + await otpFromSink(page, user.getUsername()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and email otp login, wrong code", async ({ user, page }) => { + // Given email otp is enabled on the organization of the user + // Given the user has only email otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and email otp login, multiple mfa options", async ({ page }) => { + base.skip(); + // Given email otp and sms otp is enabled on the organization of the user + // Given the user has email and sms otp configured as second factor + // User enters username + // User enters password + // User receives an email with a verification code + // User clicks button to use sms otp as second factor + // User receives a sms with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts new file mode 100644 index 0000000000..10901cd243 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-otp_sms.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndPhoneOTP } from "./login"; +import { OtpType, PasswordUserWithOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + type: OtpType.sms, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, enter code manually", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test.skip("DOESN'T WORK: username, password and sms otp login, resend code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User clicks resend code + // User receives a new sms with a verification code + // User is redirected to the app (default redirect url) + await loginWithPasswordAndPhoneOTP(page, user.getUsername(), user.getPassword(), user.getPhone()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and sms otp login, wrong code", async ({ user, page }) => { + // Given sms otp is enabled on the organization of the user + // Given the user has only sms otp configured as second factor + // User enters username + // User enters password + // User receives a sms with a verification code + // User enters a wrong code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-set.spec.ts b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts new file mode 100644 index 0000000000..06ce42f1a7 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-set.spec.ts @@ -0,0 +1,52 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { resetPassword, startResetPassword } from "./password"; +import { resetPasswordScreen, resetPasswordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password set login", async ({ user, page }) => { + const changedPw = "ChangedPw1!"; + await startLogin(page); + await loginname(page, user.getUsername()); + await resetPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); + + await loginWithPassword(page, user.getUsername(), changedPw); + await loginScreenExpect(page, user.getFullName()); +}); + +test("password set not with desired complexity", async ({ user, page }) => { + const changedPw1 = "change"; + const changedPw2 = "chang"; + await startLogin(page); + await loginname(page, user.getUsername()); + await startResetPassword(page); + await resetPasswordScreen(page, user.getUsername(), changedPw1, changedPw2); + await resetPasswordScreenExpect(page, changedPw1, changedPw2, false, false, false, false, true, false); +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts new file mode 100644 index 0000000000..e495b16681 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-totp.spec.ts @@ -0,0 +1,71 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { code } from "./code"; +import { codeScreenExpect } from "./code-screen"; +import { loginScreenExpect, loginWithPassword, loginWithPasswordAndTOTP } from "./login"; +import { PasswordUserWithTOTP } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUserWithTOTP; sink: any }>({ + user: async ({ page }, use) => { + const user = new PasswordUserWithTOTP({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number({ style: "international" }), + isPhoneVerified: true, + password: "Password1!", + passwordChangeRequired: false, + }); + + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username, password and totp login", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters the code into the ui + // User is redirected to the app (default redirect url) + await loginWithPasswordAndTOTP(page, user.getUsername(), user.getPassword(), user.getSecret()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username, password and totp otp login, wrong code", async ({ user, page }) => { + // Given totp is enabled on the organization of the user + // Given the user has only totp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // User enters a wrond code + // Error message - "Invalid code" is shown + const c = "wrongcode"; + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await code(page, c); + await codeScreenExpect(page, c); +}); + +test("username, password and totp login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given totp and email otp is enabled on the organization of the user + // Given the user has totp and email otp configured as second factor + // User enters username + // User enters password + // Screen for entering the code is shown directly + // Button to switch to email otp is shown + // User clicks button to use email otp instead + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts new file mode 100644 index 0000000000..dc23064fd6 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password-u2f.spec.ts @@ -0,0 +1,26 @@ +import { test } from "@playwright/test"; + +test("username, password and u2f login", async ({ page }) => { + test.skip(); + // Given u2f is enabled on the organization of the user + // Given the user has only u2f configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User verifies u2f + // User is redirected to the app (default redirect url) +}); + +test("username, password and u2f login, multiple mfa options", async ({ page }) => { + test.skip(); + // Given u2f and semailms otp is enabled on the organization of the user + // Given the user has u2f and email otp configured as second factor + // User enters username + // User enters password + // Popup for u2f is directly opened + // User aborts u2f verification + // User clicks button to use email otp as second factor + // User receives an email with a verification code + // User enters code in ui + // User is redirected to the app (default redirect url) +}); diff --git a/login/apps/login-test-acceptance/tests/username-password.spec.ts b/login/apps/login-test-acceptance/tests/username-password.spec.ts new file mode 100644 index 0000000000..ceb340f8da --- /dev/null +++ b/login/apps/login-test-acceptance/tests/username-password.spec.ts @@ -0,0 +1,157 @@ +import { faker } from "@faker-js/faker"; +import { test as base } from "@playwright/test"; +import dotenv from "dotenv"; +import path from "path"; +import { loginScreenExpect, loginWithPassword, startLogin } from "./login"; +import { loginname } from "./loginname"; +import { loginnameScreenExpect } from "./loginname-screen"; +import { password } from "./password"; +import { passwordScreenExpect } from "./password-screen"; +import { PasswordUser } from "./user"; + +// Read from ".env" file. +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +const test = base.extend<{ user: PasswordUser }>({ + user: async ({ page }, use) => { + const user = new PasswordUser({ + email: faker.internet.email(), + isEmailVerified: true, + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + organization: "", + phone: faker.phone.number(), + isPhoneVerified: false, + password: "Password1!", + passwordChangeRequired: false, + }); + await user.ensure(page); + await use(user); + await user.cleanup(); + }, +}); + +test("username and password login", async ({ user, page }) => { + await loginWithPassword(page, user.getUsername(), user.getPassword()); + await loginScreenExpect(page, user.getFullName()); +}); + +test("username and password login, unknown username", async ({ page }) => { + const username = "unknown"; + await startLogin(page); + await loginname(page, username); + await loginnameScreenExpect(page, username); +}); + +test("username and password login, wrong password", async ({ user, page }) => { + await startLogin(page); + await loginname(page, user.getUsername()); + await password(page, "wrong"); + await passwordScreenExpect(page, "wrong"); +}); + +test("username and password login, wrong username, ignore unknown usernames", async ({ user, page }) => { + test.skip(); + // Given user doesn't exist but ignore unknown usernames setting is set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // redirect to loginname page --> error message username or password wrong +}); + +test("username and password login, initial password change", async ({ user, page }) => { + test.skip(); + // Given user is created and has changePassword set to true + // Given username password login is enabled on the users organization + // enter login name + // enter password + // create new password +}); + +test("username and password login, reset password hidden", async ({ user, page }) => { + test.skip(); + // Given the organization has enabled "Password reset hidden" in the login policy + // Given username password login is enabled on the users organization + // enter login name + // password reset link should not be shown on password screen +}); + +test("username and password login, reset password - enter code manually", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // enter code from email + // user is redirected to app (default redirect url) +}); + +test("username and password login, reset password - click link", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button, and then the link in the email + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click link in email + // set new password + // redirect to app (default redirect url) +}); + +test("username and password login, reset password, resend code", async ({ user, page }) => { + test.skip(); + // Given user has forgotten password and clicks the forgot password button and then resend code + // Given username password login is enabled on the users organization + // enter login name + // click password forgotten + // click resend code + // enter code from second email + // user is redirected to app (default redirect url) +}); + +test("email login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com " in login screen + // user will get to password screen +}); + +test("email login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same email address exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("email login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter email address "test@zitadel.com" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phon number exists + // enter phone number "0711111111" in login screen + // user will get to password screen +}); + +test("phone login disabled", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given no other user with the same phone number exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); + +test("phone login enabled - multiple users", async ({ user, page }) => { + test.skip(); + // Given user with the username "testuser", email test@zitadel.com and phone number 0711111111 exists + // Given a second user with the username "testuser2", email test@zitadel.com and phone number 0711111111 exists + // enter phone number "0711111111" in login screen + // user will see error message "user not found" +}); diff --git a/login/apps/login-test-acceptance/tests/welcome.ts b/login/apps/login-test-acceptance/tests/welcome.ts new file mode 100644 index 0000000000..34267c2bd0 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/welcome.ts @@ -0,0 +1,6 @@ +import { test } from "@playwright/test"; + +test("login is accessible", async ({ page }) => { + await page.goto("./"); + await page.getByRole("heading", { name: "Welcome back!" }).isVisible(); +}); diff --git a/login/apps/login-test-acceptance/tests/zitadel.ts b/login/apps/login-test-acceptance/tests/zitadel.ts new file mode 100644 index 0000000000..b252654f86 --- /dev/null +++ b/login/apps/login-test-acceptance/tests/zitadel.ts @@ -0,0 +1,190 @@ +import { Authenticator } from "@otplib/core"; +import { createDigest, createRandomBytes } from "@otplib/plugin-crypto"; +import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two"; // use your chosen base32 plugin +import axios from "axios"; +import dotenv from "dotenv"; +import { request } from "gaxios"; +import path from "path"; +import { OtpType, userProps } from "./user"; + +dotenv.config({ path: path.resolve(__dirname, "../../login/.env.test.local") }); + +export async function addUser(props: userProps) { + const body = { + username: props.email, + organization: { + orgId: props.organization, + }, + profile: { + givenName: props.firstName, + familyName: props.lastName, + }, + email: { + email: props.email, + isVerified: true, + }, + phone: { + phone: props.phone, + isVerified: true, + }, + password: { + password: props.password, + changeRequired: props.passwordChangeRequired ?? false, + }, + }; + if (!props.isEmailVerified) { + delete body.email.isVerified; + } + if (!props.isPhoneVerified) { + delete body.phone.isVerified; + } + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users/human`, body); +} + +export async function removeUserByUsername(username: string) { + const resp = await getUserByUsername(username); + if (!resp || !resp.result || !resp.result[0]) { + return; + } + await removeUser(resp.result[0].userId); +} + +export async function removeUser(id: string) { + await deleteCall(`${process.env.ZITADEL_API_URL}/v2/users/${id}`); +} + +async function deleteCall(url: string) { + try { + const response = await axios.delete(url, { + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400 && response.status !== 404) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function getUserByUsername(username: string): Promise { + const listUsersBody = { + queries: [ + { + userNameQuery: { + userName: username, + }, + }, + ], + }; + + return await listCall(`${process.env.ZITADEL_API_URL}/v2/users`, listUsersBody); +} + +async function listCall(url: string, data: any): Promise { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + + return response.data; + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function activateOTP(userId: string, type: OtpType) { + let url = "otp_"; + switch (type) { + case OtpType.sms: + url = url + "sms"; + break; + case OtpType.email: + url = url + "email"; + break; + } + + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/${url}`, {}); +} + +async function pushCall(url: string, data: any) { + try { + const response = await axios.post(url, data, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + }, + }); + + if (response.status >= 400) { + const error = `HTTP Error: ${response.status} - ${response.statusText}`; + console.error(error); + throw new Error(error); + } + } catch (error) { + console.error("Error making request:", error); + throw error; + } +} + +export async function addTOTP(userId: string): Promise { + const response = await listCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp`, {}); + const code = totp(response.secret); + await pushCall(`${process.env.ZITADEL_API_URL}/v2/users/${userId}/totp/verify`, { code: code }); + return response.secret; +} + +export function totp(secret: string) { + const authenticator = new Authenticator({ + createDigest, + createRandomBytes, + keyDecoder, + keyEncoder, + }); + // google authenticator usage + const token = authenticator.generate(secret); + + // check if token can be used + if (!authenticator.verify({ token: token, secret: secret })) { + const error = `Generated token could not be verified`; + console.error(error); + throw new Error(error); + } + + return token; +} + +export async function eventualNewUser(id: string) { + return request({ + url: `${process.env.ZITADEL_API_URL}/v2/users/${id}`, + method: "GET", + headers: { + Authorization: `Bearer ${process.env.ZITADEL_ADMIN_TOKEN}`, + "Content-Type": "application/json", + }, + retryConfig: { + statusCodesToRetry: [[404, 404]], + retry: Number.MAX_SAFE_INTEGER, // totalTimeout limits the number of retries + totalTimeout: 10000, // 10 seconds + onRetryAttempt: (error) => { + console.warn(`Retrying to query new user ${id}: ${error.message}`); + }, + }, + }); +} diff --git a/login/apps/login-test-acceptance/turbo.json b/login/apps/login-test-acceptance/turbo.json new file mode 100644 index 0000000000..3be0539d0f --- /dev/null +++ b/login/apps/login-test-acceptance/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:acceptance:setup:dev": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login-test-acceptance/zitadel.yaml b/login/apps/login-test-acceptance/zitadel.yaml new file mode 100644 index 0000000000..3ddeaf67f0 --- /dev/null +++ b/login/apps/login-test-acceptance/zitadel.yaml @@ -0,0 +1,83 @@ +ExternalDomain: 127.0.0.1.sslip.io +ExternalSecure: true +ExternalPort: 443 +TLS.Enabled: false + +FirstInstance: + PatPath: /pat/zitadel-admin-sa.pat + Org: + Human: + UserName: zitadel-admin + FirstName: ZITADEL + LastName: Admin + Password: Password1! + PasswordChangeRequired: false + PreferredLanguage: en + Machine: + Machine: + Username: zitadel-admin-sa + Name: Admin + Pat: + ExpirationDate: 2099-01-01T00:00:00Z + +DefaultInstance: + LoginPolicy: + AllowUsernamePassword: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWUSERNAMEPASSWORD + AllowRegister: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWREGISTER + AllowExternalIDP: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWEXTERNALIDP + ForceMFA: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_FORCEMFA + HidePasswordReset: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_HIDEPASSWORDRESET + IgnoreUnknownUsernames: false # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_IGNOREUNKNOWNUSERNAMES + AllowDomainDiscovery: true # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_ALLOWDOMAINDISCOVERY + # 1 is allowed, 0 is not allowed + PasswordlessType: 1 # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDLESSTYPE + # DefaultRedirectURL is empty by default because we use the Console UI + DefaultRedirectURI: # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_DEFAULTREDIRECTURI + # 240h = 10d + PasswordCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_PASSWORDCHECKLIFETIME + # 240h = 10d + ExternalLoginCheckLifetime: 240h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_EXTERNALLOGINCHECKLIFETIME + # 720h = 30d + MfaInitSkipLifetime: 0h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MFAINITSKIPLIFETIME + SecondFactorCheckLifetime: 18h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_SECONDFACTORCHECKLIFETIME + MultiFactorCheckLifetime: 12h # ZITADEL_DEFAULTINSTANCE_LOGINPOLICY_MULTIFACTORCHECKLIFETIME + PrivacyPolicy: + TOSLink: "https://zitadel.com/docs/legal/terms-of-service" + PrivacyLink: "https://zitadel.com/docs/legal/policies/privacy-policy" + HelpLink: "https://zitadel.com/docs" + SupportEmail: "support@zitadel.com" + DocsLink: "https://zitadel.com/docs" + Features: + LoginV2: + Required: true + +OIDC: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +SAML: + DefaultLoginURLV2: "/ui/v2/login/login?authRequest=" + +Database: + EventPushConnRatio: 0.2 # 4 + ProjectionSpoolerConnRatio: 0.3 # 6 + postgres: + Host: db + Port: 5432 + Database: zitadel + MaxOpenConns: 20 + MaxIdleConns: 20 + MaxConnLifetime: 1h + MaxConnIdleTime: 5m + User: + Username: zitadel + SSL: + Mode: disable + Admin: + Username: zitadel + SSL: + Mode: disable + +Logstore: + Access: + Stdout: + Enabled: true diff --git a/login/apps/login-test-integration/.gitignore b/login/apps/login-test-integration/.gitignore new file mode 100644 index 0000000000..2ca81ab137 --- /dev/null +++ b/login/apps/login-test-integration/.gitignore @@ -0,0 +1,2 @@ +screenshots +videos \ No newline at end of file diff --git a/login/apps/login-test-integration/core-mock/Dockerfile b/login/apps/login-test-integration/core-mock/Dockerfile new file mode 100644 index 0000000000..469147d17d --- /dev/null +++ b/login/apps/login-test-integration/core-mock/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.20.5-alpine3.18 + +RUN go install github.com/eliobischof/grpc-mock/cmd/grpc-mock@01b09f60db1b501178af59bed03b2c22661df48c + +COPY mocked-services.cfg . +COPY initial-stubs initial-stubs +COPY --from=protos . . + +ENTRYPOINT [ "sh", "-c", "grpc-mock -v 1 -proto $(tr '\n' ',' < ./mocked-services.cfg) -stub-dir ./initial-stubs -mock-addr :22222" ] diff --git a/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json new file mode 100644 index 0000000000..3da4ae999f --- /dev/null +++ b/login/apps/login-test-integration/core-mock/initial-stubs/zitadel.settings.v2.SettingsService.json @@ -0,0 +1,59 @@ +[ + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetBrandingSettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetSecuritySettings", + "out": { + "data": {} + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetLegalAndSupportSettings", + "out": { + "data": { + "settings": { + "tosLink": "http://whatever.com/help", + "privacyPolicyLink": "http://whatever.com/help", + "helpLink": "http://whatever.com/help" + } + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetActiveIdentityProviders", + "out": { + "data": { + "identityProviders": [ + { + "id": "123", + "name": "Hubba bubba", + "type": 10 + } + ] + } + } + }, + { + "service": "zitadel.settings.v2.SettingsService", + "method": "GetPasswordComplexitySettings", + "out": { + "data": { + "settings": { + "minLength": 8, + "requiresUppercase": true, + "requiresLowercase": true, + "requiresNumber": true, + "requiresSymbol": true + } + } + } + } +] diff --git a/login/apps/login-test-integration/core-mock/mocked-services.cfg b/login/apps/login-test-integration/core-mock/mocked-services.cfg new file mode 100644 index 0000000000..6a758ab8c1 --- /dev/null +++ b/login/apps/login-test-integration/core-mock/mocked-services.cfg @@ -0,0 +1,7 @@ +zitadel/user/v2/user_service.proto +zitadel/org/v2/org_service.proto +zitadel/session/v2/session_service.proto +zitadel/settings/v2/settings_service.proto +zitadel/management.proto +zitadel/auth.proto +zitadel/admin.proto \ No newline at end of file diff --git a/login/apps/login-test-integration/cypress.config.ts b/login/apps/login-test-integration/cypress.config.ts new file mode 100644 index 0000000000..080cb31bc6 --- /dev/null +++ b/login/apps/login-test-integration/cypress.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "cypress"; + +export default defineConfig({ + reporter: "list", + + e2e: { + baseUrl: process.env.LOGIN_BASE_URL || "http://localhost:3000", + specPattern: "integration/**/*.cy.{js,jsx,ts,tsx}", + supportFile: "support/e2e.{js,jsx,ts,tsx}", + setupNodeEvents(on, config) { + // implement node event listeners here + }, + }, +}); diff --git a/login/apps/login-test-integration/docker-compose.yaml b/login/apps/login-test-integration/docker-compose.yaml new file mode 100644 index 0000000000..2f09a2253e --- /dev/null +++ b/login/apps/login-test-integration/docker-compose.yaml @@ -0,0 +1,30 @@ +services: + core-mock: + image: "${LOGIN_CORE_MOCK_TAG:-login-core-mock:local}" + container_name: integration-core-mock + ports: + - 22220:22220 + - 22222:22222 + + login: + image: "${LOGIN_TAG:-login:local}" + container_name: integration-login + ports: + - 3001:3001 + environment: + - PORT=3001 + - ZITADEL_API_URL=http://core-mock:22222 + - ZITADEL_SERVICE_USER_TOKEN="yolo" + - EMAIL_VERIFICATION=true + + integration: + image: "${LOGIN_TEST_INTEGRATION_TAG:-login-test-integration:local}" + container_name: integration + environment: + - LOGIN_BASE_URL=http://login:3001/ui/v2/login + - CYPRESS_CORE_MOCK_STUBS_URL=http://core-mock:22220/v1/stubs + depends_on: + login: + condition: service_started + core-mock: + condition: service_started diff --git a/login/apps/login-test-integration/fixtures/example.json b/login/apps/login-test-integration/fixtures/example.json new file mode 100644 index 0000000000..02e4254378 --- /dev/null +++ b/login/apps/login-test-integration/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/login/apps/login-test-integration/integration/invite.cy.ts b/login/apps/login-test-integration/integration/invite.cy.ts new file mode 100644 index 0000000000..a68ff96c36 --- /dev/null +++ b/login/apps/login-test-integration/integration/invite.cy.ts @@ -0,0 +1,110 @@ +import { stub } from "../support/e2e"; + +describe("verify invite", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [], // user with no auth methods was invited + }, + }); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + + it.only("shows authenticators after successful invite verification", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode"); + + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/authenticator/set"); + }); + + it("shows an error if invite code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyInviteCode", { + code: 3, + error: "error validating code", + }); + + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.contains("Could not verify invite", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/integration/login.cy.ts b/login/apps/login-test-integration/integration/login.cy.ts new file mode 100644 index 0000000000..917d719cb1 --- /dev/null +++ b/login/apps/login-test-integration/integration/login.cy.ts @@ -0,0 +1,172 @@ +import { stub } from "../support/e2e"; + +describe("login", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowUsernamePassword: true, + }, + }, + }); + }); + describe("password login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // 1 for password authentication + }, + }); + }); + it("should redirect a user with password authentication to /password", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/password"); + }); + describe("with passkey prompt", () => { + beforeEach(() => { + stub("zitadel.session.v2.SessionService", "SetSession", { + data: { + details: { + sequence: 859, + changeDate: "2023-07-04T07:58:20.126Z", + resourceOwner: "220516472055706145", + }, + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + }); + // it("should prompt a user to setup passwordless authentication if passkey is allowed in the login settings", () => { + // cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + // cy.location("pathname", { timeout: 10_000 }).should("eq", "/password"); + // cy.get('input[type="password"]').focus().type("MyStrongPassword!1"); + // cy.get('button[type="submit"]').click(); + // cy.location("pathname", { timeout: 10_000 }).should( + // "eq", + // "/passkey/set", + // ); + // }); + }); + }); + describe("passkey login", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "ListUsers", { + data: { + details: { + totalResult: 1, + }, + result: [ + { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: true, + }, + }, + }, + ], + }, + }); + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [2], // 2 for passwordless authentication + }, + }); + }); + + it("should redirect a user with passwordless authentication to /passkey", () => { + cy.visit("/loginname?loginName=john%40zitadel.com&submit=true"); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey"); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register-idp.cy.ts b/login/apps/login-test-integration/integration/register-idp.cy.ts new file mode 100644 index 0000000000..73a0c32e00 --- /dev/null +++ b/login/apps/login-test-integration/integration/register-idp.cy.ts @@ -0,0 +1,21 @@ +import { stub } from "../support/e2e"; + +const IDP_URL = "https://example.com/idp/url"; + +describe("register idps", () => { + beforeEach(() => { + stub("zitadel.user.v2.UserService", "StartIdentityProviderIntent", { + data: { + authUrl: IDP_URL, + }, + }); + }); + + it("should redirect the user to the correct url", () => { + cy.visit("/idp"); + cy.get('button[e2e="google"]').click(); + cy.origin(IDP_URL, { args: IDP_URL }, (url) => { + cy.location("href", { timeout: 10_000 }).should("eq", url); + }); + }); +}); diff --git a/login/apps/login-test-integration/integration/register.cy.ts b/login/apps/login-test-integration/integration/register.cy.ts new file mode 100644 index 0000000000..44c53647c1 --- /dev/null +++ b/login/apps/login-test-integration/integration/register.cy.ts @@ -0,0 +1,73 @@ +import { stub } from "../support/e2e"; + +describe("register", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + stub("zitadel.settings.v2.SettingsService", "GetLoginSettings", { + data: { + settings: { + passkeysType: 1, + allowRegister: true, + allowUsernamePassword: true, + defaultRedirectUri: "", + }, + }, + }); + stub("zitadel.user.v2.UserService", "AddHumanUser", { + data: { + userId: "221394658884845598", + }, + }); + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("should redirect a user who selects passwordless on register to /passkey/set", () => { + cy.visit("/register"); + cy.get('input[data-testid="firstname-text-input"]').focus().type("John"); + cy.get('input[data-testid="lastname-text-input"]').focus().type("Doe"); + cy.get('input[data-testid="email-text-input"]').focus().type("john@zitadel.com"); + cy.get('input[type="checkbox"][value="privacypolicy"]').check(); + cy.get('input[type="checkbox"][value="tos"]').check(); + cy.get('button[type="submit"]').click(); + cy.url({ timeout: 10_000 }).should("include", Cypress.config().baseUrl + "/passkey/set"); + }); +}); diff --git a/login/apps/login-test-integration/integration/verify.cy.ts b/login/apps/login-test-integration/integration/verify.cy.ts new file mode 100644 index 0000000000..db80cea720 --- /dev/null +++ b/login/apps/login-test-integration/integration/verify.cy.ts @@ -0,0 +1,95 @@ +import { stub } from "../support/e2e"; + +describe("verify email", () => { + beforeEach(() => { + stub("zitadel.org.v2.OrganizationService", "ListOrganizations", { + data: { + details: { + totalResult: 1, + }, + result: [{ id: "256088834543534543" }], + }, + }); + + stub("zitadel.user.v2.UserService", "ListAuthenticationMethodTypes", { + data: { + authMethodTypes: [1], // set one method such that we know that the user was not invited + }, + }); + + stub("zitadel.user.v2.UserService", "SendEmailCode"); + + stub("zitadel.user.v2.UserService", "GetUserByID", { + data: { + user: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + human: { + userId: "221394658884845598", + state: 1, + username: "john@zitadel.com", + loginNames: ["john@zitadel.com"], + preferredLoginName: "john@zitadel.com", + profile: { + givenName: "John", + familyName: "Doe", + avatarUrl: "https://zitadel.com/avatar.jpg", + }, + email: { + email: "john@zitadel.com", + isVerified: false, // email is not verified yet + }, + }, + }, + }, + }); + + stub("zitadel.session.v2.SessionService", "CreateSession", { + data: { + details: { + sequence: 859, + changeDate: new Date("2024-04-04T09:40:55.577Z"), + resourceOwner: "220516472055706145", + }, + sessionId: "221394658884845598", + sessionToken: "SDMc7DlYXPgwRJ-Tb5NlLqynysHjEae3csWsKzoZWLplRji0AYY3HgAkrUEBqtLCvOayLJPMd0ax4Q", + challenges: undefined, + }, + }); + + stub("zitadel.session.v2.SessionService", "GetSession", { + data: { + session: { + id: "221394658884845598", + creationDate: new Date("2024-04-04T09:40:55.577Z"), + changeDate: new Date("2024-04-04T09:40:55.577Z"), + sequence: 859, + factors: { + user: { + id: "221394658884845598", + loginName: "john@zitadel.com", + }, + password: undefined, + webAuthN: undefined, + intent: undefined, + }, + metadata: {}, + }, + }, + }); + }); + + it("shows an error if email code validation failed", () => { + stub("zitadel.user.v2.UserService", "VerifyEmail", { + code: 3, + error: "error validating code", + }); + // TODO: Avoid uncaught exception in application + cy.once("uncaught:exception", () => false); + cy.visit("/verify?userId=221394658884845598&code=abc"); + cy.contains("Could not verify email", { timeout: 10_000 }); + }); +}); diff --git a/login/apps/login-test-integration/package.json b/login/apps/login-test-integration/package.json new file mode 100644 index 0000000000..f45c5a3413 --- /dev/null +++ b/login/apps/login-test-integration/package.json @@ -0,0 +1,17 @@ +{ + "name": "login-test-integration", + "private": true, + "scripts": { + "test:integration": "dotenv -e ../login/.env.test pnpm exec cypress", + "test:integration:setup": "cd ../.. && make login_test_integration_dev" + }, + "devDependencies": { + "@types/node": "^22.14.1", + "concurrently": "^9.1.2", + "cypress": "^14.3.2", + "env-cmd": "^10.0.0", + "nodemon": "^3.1.9", + "start-server-and-test": "^2.0.11", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login-test-integration/support/e2e.ts b/login/apps/login-test-integration/support/e2e.ts new file mode 100644 index 0000000000..58056c973e --- /dev/null +++ b/login/apps/login-test-integration/support/e2e.ts @@ -0,0 +1,29 @@ +const url = Cypress.env("CORE_MOCK_STUBS_URL") || "http://localhost:22220/v1/stubs"; + +function removeStub(service: string, method: string) { + return cy.request({ + url, + method: "DELETE", + qs: { + service, + method, + }, + }); +} + +export function stub(service: string, method: string, out?: any) { + removeStub(service, method); + return cy.request({ + url, + method: "POST", + body: { + stubs: [ + { + service, + method, + out, + }, + ], + }, + }); +} diff --git a/login/apps/login-test-integration/tsconfig.json b/login/apps/login-test-integration/tsconfig.json new file mode 100644 index 0000000000..18edb199ac --- /dev/null +++ b/login/apps/login-test-integration/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "node"] + }, + "include": ["**/*.ts"] +} diff --git a/login/apps/login-test-integration/turbo.json b/login/apps/login-test-integration/turbo.json new file mode 100644 index 0000000000..2e2c7cfb42 --- /dev/null +++ b/login/apps/login-test-integration/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "test:integration:setup": { + "interactive": true, + "cache": false, + "persistent": true + } + } +} diff --git a/login/apps/login/.env.test b/login/apps/login/.env.test new file mode 100644 index 0000000000..ee70003348 --- /dev/null +++ b/login/apps/login/.env.test @@ -0,0 +1,5 @@ +NEXT_PUBLIC_BASE_PATH="" +ZITADEL_API_URL=http://localhost:22222 +ZITADEL_SERVICE_USER_TOKEN="yolo" +EMAIL_VERIFICATION=true +DEBUG=true diff --git a/login/apps/login/.eslintrc.cjs b/login/apps/login/.eslintrc.cjs new file mode 100755 index 0000000000..f5383dd47a --- /dev/null +++ b/login/apps/login/.eslintrc.cjs @@ -0,0 +1,12 @@ +module.exports = { + extends: ["next/core-web-vitals"], + ignorePatterns: ["external/**/*.ts"], + rules: { + "@next/next/no-html-link-for-pages": "off", + }, + settings: { + react: { + version: "detect", + }, + }, +}; diff --git a/login/apps/login/.gitignore b/login/apps/login/.gitignore new file mode 100644 index 0000000000..caf3c1ec81 --- /dev/null +++ b/login/apps/login/.gitignore @@ -0,0 +1,3 @@ +custom-config.js +.env*.local +standalone diff --git a/login/apps/login/.prettierignore b/login/apps/login/.prettierignore new file mode 100644 index 0000000000..dbcbbd11d1 --- /dev/null +++ b/login/apps/login/.prettierignore @@ -0,0 +1,2 @@ +.next +/external \ No newline at end of file diff --git a/login/apps/login/constants/csp.js b/login/apps/login/constants/csp.js new file mode 100644 index 0000000000..5cc1e254f3 --- /dev/null +++ b/login/apps/login/constants/csp.js @@ -0,0 +1,2 @@ +export const DEFAULT_CSP = + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://va.vercel-scripts.com; connect-src 'self'; child-src; style-src 'self' 'unsafe-inline'; font-src 'self'; object-src 'none'; img-src 'self' https://vercel.com;"; diff --git a/login/apps/login/locales/de.json b/login/apps/login/locales/de.json new file mode 100644 index 0000000000..75897a628e --- /dev/null +++ b/login/apps/login/locales/de.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Zurück" + }, + "accounts": { + "title": "Konten", + "description": "Wählen Sie das Konto aus, das Sie verwenden möchten.", + "addAnother": "Ein weiteres Konto hinzufügen", + "noResults": "Keine Konten gefunden" + }, + "logout": { + "title": "Logout", + "description": "Wählen Sie den Account aus, das Sie entfernen möchten", + "noResults": "Keine Konten gefunden", + "clear": "Session beenden", + "verifiedAt": "Zuletzt aktiv: {time}", + "success": { + "title": "Logout erfolgreich", + "description": "Sie haben sich erfolgreich abgemeldet." + } + }, + "loginname": { + "title": "Willkommen zurück!", + "description": "Geben Sie Ihre Anmeldedaten ein.", + "register": "Neuen Benutzer registrieren", + "submit": "Weiter" + }, + "password": { + "verify": { + "title": "Passwort", + "description": "Geben Sie Ihr Passwort ein.", + "resetPassword": "Passwort zurücksetzen", + "submit": "Weiter" + }, + "set": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "codeSent": "Ein Code wurde an Ihre E-Mail-Adresse gesendet.", + "noCodeReceived": "Keinen Code erhalten?", + "resend": "Erneut senden", + "submit": "Weiter" + }, + "change": { + "title": "Passwort ändern", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "idp": { + "title": "Mit SSO anmelden", + "description": "Wählen Sie einen der folgenden Anbieter, um sich anzumelden", + "orSignInWith": "oder melden Sie sich an mit", + "signInWithApple": "Mit Apple anmelden", + "signInWithGoogle": "Mit Google anmelden", + "signInWithAzureAD": "Mit AzureAD anmelden", + "signInWithGithub": "Mit GitHub anmelden", + "signInWithGitlab": "Mit GitLab anmelden", + "loginSuccess": { + "title": "Anmeldung erfolgreich", + "description": "Sie haben sich erfolgreich angemeldet!" + }, + "linkingSuccess": { + "title": "Konto verknüpft", + "description": "Sie haben Ihr Konto erfolgreich verknüpft!" + }, + "registerSuccess": { + "title": "Registrierung erfolgreich", + "description": "Sie haben sich erfolgreich registriert!" + }, + "loginError": { + "title": "Anmeldung fehlgeschlagen", + "description": "Beim Anmelden ist ein Fehler aufgetreten." + }, + "linkingError": { + "title": "Konto-Verknüpfung fehlgeschlagen", + "description": "Beim Verknüpfen Ihres Kontos ist ein Fehler aufgetreten." + }, + "completeRegister": { + "title": "Registrierung abschließen", + "description": "Bitte vervollständige die Registrierung, um dein Konto zu erstellen." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Geben Sie Ihre LDAP-Anmeldedaten ein.", + "username": "Benutzername", + "password": "Passwort", + "submit": "Weiter" + }, + "mfa": { + "verify": { + "title": "Bestätigen Sie Ihre Identität", + "description": "Wählen Sie einen der folgenden Faktoren.", + "noResults": "Keine zweiten Faktoren verfügbar, um sie einzurichten." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Wählen Sie einen der folgenden zweiten Faktoren.", + "skip": "Überspringen" + } + }, + "otp": { + "verify": { + "title": "2-Faktor bestätigen", + "totpDescription": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein.", + "smsDescription": "Geben Sie den Code ein, den Sie per SMS erhalten haben.", + "emailDescription": "Geben Sie den Code ein, den Sie per E-Mail erhalten haben.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "submit": "Weiter" + }, + "set": { + "title": "2-Faktor einrichten", + "totpDescription": "Scannen Sie den QR-Code mit Ihrer Authentifizierungs-App.", + "smsDescription": "Geben Sie Ihre Telefonnummer ein, um einen Code per SMS zu erhalten.", + "emailDescription": "Geben Sie Ihre E-Mail-Adresse ein, um einen Code per E-Mail zu erhalten.", + "totpRegisterDescription": "Scannen Sie den QR-Code oder navigieren Sie manuell zur URL.", + "submit": "Weiter" + } + }, + "passkey": { + "verify": { + "title": "Mit einem Passkey authentifizieren", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "usePassword": "Passwort verwenden", + "submit": "Weiter" + }, + "set": { + "title": "Passkey einrichten", + "description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen", + "info": { + "description": "Ein Passkey ist eine Authentifizierungsmethode auf einem Gerät wie Ihr Fingerabdruck, Apple FaceID oder ähnliches.", + "link": "Passwortlose Authentifizierung" + }, + "skip": "Überspringen", + "submit": "Weiter" + } + }, + "u2f": { + "verify": { + "title": "2-Faktor bestätigen", + "description": "Bestätigen Sie Ihr Konto mit Ihrem Gerät." + }, + "set": { + "title": "2-Faktor einrichten", + "description": "Richten Sie ein Gerät als zweiten Faktor ein.", + "submit": "Weiter" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registrierung deaktiviert", + "description": "Die Registrierung ist deaktiviert. Bitte wenden Sie sich an den Administrator." + }, + "missingdata": { + "title": "Registrierung fehlgeschlagen", + "description": "Einige Daten fehlen. Bitte überprüfen Sie Ihre Eingaben." + }, + "title": "Registrieren", + "description": "Erstellen Sie Ihr ZITADEL-Konto.", + "noMethodAvailableWarning": "Keine Authentifizierungsmethode verfügbar. Bitte wenden Sie sich an den Administrator.", + "selectMethod": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten", + "agreeTo": "Um sich zu registrieren, müssen Sie den Nutzungsbedingungen zustimmen", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzrichtlinie", + "submit": "Weiter", + "orUseIDP": "oder verwenden Sie einen Identitätsanbieter", + "password": { + "title": "Passwort festlegen", + "description": "Legen Sie das Passwort für Ihr Konto fest", + "submit": "Weiter" + } + }, + "invite": { + "title": "Benutzer einladen", + "description": "Geben Sie die E-Mail-Adresse des Benutzers ein, den Sie einladen möchten.", + "info": "Der Benutzer erhält eine E-Mail mit einem Link, um sich zu registrieren.", + "notAllowed": "Sie haben keine Berechtigung, Benutzer einzuladen.", + "submit": "Einladen", + "success": { + "title": "Einladung erfolgreich", + "description": "Der Benutzer wurde erfolgreich eingeladen.", + "verified": "Der Benutzer wurde eingeladen und hat seine E-Mail bereits verifiziert.", + "notVerifiedYet": "Der Benutzer wurde eingeladen. Er erhält eine E-Mail mit weiteren Anweisungen.", + "submit": "Weiteren Benutzer einladen" + } + }, + "signedin": { + "title": "Willkommen {user}!", + "description": "Sie sind angemeldet.", + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } + }, + "verify": { + "userIdMissing": "Keine Benutzer-ID angegeben!", + "successTitle": "Benutzer verifiziert", + "successDescription": "Der Benutzer wurde erfolgreich verifiziert.", + "setupAuthenticator": "Authentifikator einrichten", + "verify": { + "title": "Benutzer verifizieren", + "description": "Geben Sie den Code ein, der in der Bestätigungs-E-Mail angegeben ist.", + "noCodeReceived": "Keinen Code erhalten?", + "resendCode": "Code erneut senden", + "codeSent": "Ein Code wurde gerade an Ihre E-Mail-Adresse gesendet.", + "submit": "Weiter" + } + }, + "authenticator": { + "title": "Authentifizierungsmethode auswählen", + "description": "Wählen Sie die Methode, mit der Sie sich authentifizieren möchten.", + "noMethodsAvailable": "Keine Authentifizierungsmethoden verfügbar", + "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", + "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" + }, + "device": { + "usercode": { + "title": "Gerätecode", + "description": "Geben Sie den Code ein.", + "submit": "Weiter" + }, + "request": { + "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "submit": "Zulassen", + "deny": "Ablehnen" + }, + "scope": { + "openid": "Überprüfen Ihrer Identität.", + "email": "Zugriff auf Ihre E-Mail-Adresse.", + "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", + "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." + } + }, + "error": { + "noUserCode": "Kein Benutzercode angegeben!", + "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", + "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", + "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", + "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", + "tryagain": "Erneut versuchen" + } +} diff --git a/login/apps/login/locales/en.json b/login/apps/login/locales/en.json new file mode 100644 index 0000000000..9f95403063 --- /dev/null +++ b/login/apps/login/locales/en.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Back" + }, + "accounts": { + "title": "Accounts", + "description": "Select the account you want to use.", + "addAnother": "Add another account", + "noResults": "No accounts found" + }, + "logout": { + "title": "Logout", + "description": "Click an account to end the session", + "noResults": "No accounts found", + "clear": "End Session", + "verifiedAt": "Last active: {time}", + "success": { + "title": "Logout successful", + "description": "You have successfully logged out." + } + }, + "loginname": { + "title": "Welcome back!", + "description": "Enter your login data.", + "register": "Register new user", + "submit": "Continue" + }, + "password": { + "verify": { + "title": "Password", + "description": "Enter your password.", + "resetPassword": "Reset Password", + "submit": "Continue" + }, + "set": { + "title": "Set Password", + "description": "Set the password for your account", + "codeSent": "A code has been sent to your email address.", + "noCodeReceived": "Didn't receive a code?", + "resend": "Resend code", + "submit": "Continue" + }, + "change": { + "title": "Change Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "idp": { + "title": "Sign in with SSO", + "description": "Select one of the following providers to sign in", + "orSignInWith": "or sign in with", + "signInWithApple": "Sign in with Apple", + "signInWithGoogle": "Sign in with Google", + "signInWithAzureAD": "Sign in with AzureAD", + "signInWithGithub": "Sign in with GitHub", + "signInWithGitlab": "Sign in with GitLab", + "loginSuccess": { + "title": "Login successful", + "description": "You have successfully been loggedIn!" + }, + "linkingSuccess": { + "title": "Account linked", + "description": "You have successfully linked your account!" + }, + "registerSuccess": { + "title": "Registration successful", + "description": "You have successfully registered!" + }, + "loginError": { + "title": "Login failed", + "description": "An error occurred while trying to login." + }, + "linkingError": { + "title": "Account linking failed", + "description": "An error occurred while trying to link your account." + }, + "completeRegister": { + "title": "Complete your data", + "description": "You need to complete your registration by providing your email address and name." + } + }, + "ldap": { + "title": "LDAP Login", + "description": "Enter your LDAP credentials.", + "username": "Username", + "password": "Password", + "submit": "Continue" + }, + "mfa": { + "verify": { + "title": "Verify your identity", + "description": "Choose one of the following factors.", + "noResults": "No second factors available to setup." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Choose one of the following second factors.", + "skip": "Skip" + } + }, + "otp": { + "verify": { + "title": "Verify 2-Factor", + "totpDescription": "Enter the code from your authenticator app.", + "smsDescription": "Enter the code you received via SMS.", + "emailDescription": "Enter the code you received via email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "submit": "Continue" + }, + "set": { + "title": "Set up 2-Factor", + "totpDescription": "Scan the QR code with your authenticator app.", + "smsDescription": "Enter your phone number to receive a code via SMS.", + "emailDescription": "Enter your email address to receive a code via email.", + "totpRegisterDescription": "Scan the QR Code or navigate to the URL manually.", + "submit": "Continue" + } + }, + "passkey": { + "verify": { + "title": "Authenticate with a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "usePassword": "Use password", + "submit": "Continue" + }, + "set": { + "title": "Setup a passkey", + "description": "Your device will ask for your fingerprint, face, or screen lock", + "info": { + "description": "A passkey is an authentication method on a device like your fingerprint, Apple FaceID or similar. ", + "link": "Passwordless Authentication" + }, + "skip": "Skip", + "submit": "Continue" + } + }, + "u2f": { + "verify": { + "title": "Verify 2-Factor", + "description": "Verify your account with your device." + }, + "set": { + "title": "Set up 2-Factor", + "description": "Set up a device as a second factor.", + "submit": "Continue" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "The registration is disabled. Please contact your administrator." + }, + "missingdata": { + "title": "Missing data", + "description": "Provide email, first and last name to register." + }, + "title": "Register", + "description": "Create your ZITADEL account.", + "noMethodAvailableWarning": "No authentication method available. Please contact your administrator.", + "selectMethod": "Select the method you would like to authenticate", + "agreeTo": "To register you must agree to the terms and conditions", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "submit": "Continue", + "orUseIDP": "or use an Identity Provider", + "password": { + "title": "Set Password", + "description": "Set the password for your account", + "submit": "Continue" + } + }, + "invite": { + "title": "Invite User", + "description": "Provide the email address and the name of the user you want to invite.", + "info": "The user will receive an email with further instructions.", + "notAllowed": "Your settings do not allow you to invite users.", + "submit": "Continue", + "success": { + "title": "User invited", + "description": "The email has successfully been sent.", + "verified": "The user has been invited and has already verified his email.", + "notVerifiedYet": "The user has been invited. They will receive an email with further instructions.", + "submit": "Invite another user" + } + }, + "signedin": { + "title": "Welcome {user}!", + "description": "You are signed in.", + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } + }, + "verify": { + "userIdMissing": "No userId provided!", + "successTitle": "User verified", + "successDescription": "The user has been verified successfully.", + "setupAuthenticator": "Setup authenticator", + "verify": { + "title": "Verify user", + "description": "Enter the Code provided in the verification email.", + "noCodeReceived": "Didn't receive a code?", + "resendCode": "Resend code", + "codeSent": "A code has just been sent to your email address.", + "submit": "Continue" + } + }, + "authenticator": { + "title": "Choose authentication method", + "description": "Select the method you would like to authenticate", + "noMethodsAvailable": "No authentication methods available", + "allSetup": "You have already setup an authenticator!", + "linkWithIDP": "or link with an Identity Provider" + }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code displayed on your app or device.", + "submit": "Continue" + }, + "request": { + "title": "{appName} would like to connect", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow", + "deny": "Deny" + }, + "scope": { + "openid": "Verify your identity.", + "email": "View your email address.", + "profile": "View your full profile information.", + "offline_access": "Allow offline access to your account." + } + }, + "error": { + "noUserCode": "No user code provided!", + "noDeviceRequest": "No device request found.", + "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", + "sessionExpired": "Your current session has expired. Please login again.", + "failedLoading": "Failed to load data. Please try again.", + "tryagain": "Try Again" + } +} diff --git a/login/apps/login/locales/es.json b/login/apps/login/locales/es.json new file mode 100644 index 0000000000..fe88bb94c6 --- /dev/null +++ b/login/apps/login/locales/es.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Atrás" + }, + "accounts": { + "title": "Cuentas", + "description": "Selecciona la cuenta que deseas usar.", + "addAnother": "Agregar otra cuenta", + "noResults": "No se encontraron cuentas" + }, + "logout": { + "title": "Cerrar sesión", + "description": "Selecciona la cuenta que deseas eliminar", + "noResults": "No se encontraron cuentas", + "clear": "Eliminar sesión", + "verifiedAt": "Última actividad: {time}", + "success": { + "title": "Cierre de sesión exitoso", + "description": "Has cerrado sesión correctamente." + } + }, + "loginname": { + "title": "¡Bienvenido de nuevo!", + "description": "Introduce tus datos de acceso.", + "register": "Registrar nuevo usuario", + "submit": "Continuar" + }, + "password": { + "verify": { + "title": "Contraseña", + "description": "Introduce tu contraseña.", + "resetPassword": "Restablecer contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "codeSent": "Se ha enviado un código a su correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resend": "Reenviar código", + "submit": "Continuar" + }, + "change": { + "title": "Cambiar Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "idp": { + "title": "Iniciar sesión con SSO", + "description": "Selecciona uno de los siguientes proveedores para iniciar sesión", + "orSignInWith": "o iniciar sesión con", + "signInWithApple": "Iniciar sesión con Apple", + "signInWithGoogle": "Iniciar sesión con Google", + "signInWithAzureAD": "Iniciar sesión con AzureAD", + "signInWithGithub": "Iniciar sesión con GitHub", + "signInWithGitlab": "Iniciar sesión con GitLab", + "loginSuccess": { + "title": "Inicio de sesión exitoso", + "description": "¡Has iniciado sesión con éxito!" + }, + "linkingSuccess": { + "title": "Cuenta vinculada", + "description": "¡Has vinculado tu cuenta con éxito!" + }, + "registerSuccess": { + "title": "Registro exitoso", + "description": "¡Te has registrado con éxito!" + }, + "loginError": { + "title": "Error de inicio de sesión", + "description": "Ocurrió un error al intentar iniciar sesión." + }, + "linkingError": { + "title": "Error al vincular la cuenta", + "description": "Ocurrió un error al intentar vincular tu cuenta." + }, + "completeRegister": { + "title": "Completar registro", + "description": "Para completar el registro, debes establecer una contraseña." + } + }, + "ldap": { + "title": "Iniciar sesión con LDAP", + "description": "Introduce tus credenciales LDAP.", + "username": "Nombre de usuario", + "password": "Contraseña", + "submit": "Continuar" + }, + "mfa": { + "verify": { + "title": "Verifica tu identidad", + "description": "Elige uno de los siguientes factores.", + "noResults": "No hay factores secundarios disponibles para configurar." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Elige uno de los siguientes factores secundarios.", + "skip": "Omitir" + } + }, + "otp": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "totpDescription": "Introduce el código de tu aplicación de autenticación.", + "smsDescription": "Introduce el código que recibiste por SMS.", + "emailDescription": "Introduce el código que recibiste por correo electrónico.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "submit": "Continuar" + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "totpDescription": "Escanea el código QR con tu aplicación de autenticación.", + "smsDescription": "Introduce tu número de teléfono para recibir un código por SMS.", + "emailDescription": "Introduce tu dirección de correo electrónico para recibir un código por correo electrónico.", + "totpRegisterDescription": "Escanea el código QR o navega manualmente a la URL.", + "submit": "Continuar" + } + }, + "passkey": { + "verify": { + "title": "Autenticar con una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "usePassword": "Usar contraseña", + "submit": "Continuar" + }, + "set": { + "title": "Configurar una clave de acceso", + "description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla", + "info": { + "description": "Una clave de acceso es un método de autenticación en un dispositivo como tu huella digital, Apple FaceID o similar.", + "link": "Autenticación sin contraseña" + }, + "skip": "Omitir", + "submit": "Continuar" + } + }, + "u2f": { + "verify": { + "title": "Verificar autenticación de 2 factores", + "description": "Verifica tu cuenta con tu dispositivo." + }, + "set": { + "title": "Configurar autenticación de 2 factores", + "description": "Configura un dispositivo como segundo factor.", + "submit": "Continuar" + } + }, + "register": { + "methods": { + "passkey": "Clave de acceso", + "password": "Contraseña" + }, + "disabled": { + "title": "Registro deshabilitado", + "description": "Registrarse está deshabilitado en este momento." + }, + "missingdata": { + "title": "Datos faltantes", + "description": "No se proporcionaron datos suficientes para el registro." + }, + "title": "Registrarse", + "description": "Crea tu cuenta ZITADEL.", + "noMethodAvailableWarning": "No hay métodos de autenticación disponibles. Por favor, contacta a tu administrador.", + "selectMethod": "Selecciona el método con el que deseas autenticarte", + "agreeTo": "Para registrarte debes aceptar los términos y condiciones", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de Privacidad", + "submit": "Continuar", + "orUseIDP": "o usa un Proveedor de Identidad", + "password": { + "title": "Establecer Contraseña", + "description": "Establece la contraseña para tu cuenta", + "submit": "Continuar" + } + }, + "invite": { + "title": "Invitar usuario", + "description": "Introduce el correo electrónico del usuario que deseas invitar.", + "info": "El usuario recibirá un correo electrónico con un enlace para completar el registro.", + "notAllowed": "No tienes permiso para invitar usuarios.", + "submit": "Invitar usuario", + "success": { + "title": "¡Usuario invitado!", + "description": "El usuario ha sido invitado.", + "verified": "El usuario ha sido invitado y ya ha verificado su correo electrónico.", + "notVerifiedYet": "El usuario ha sido invitado. Recibirá un correo electrónico con más instrucciones.", + "submit": "Invitar a otro usuario" + } + }, + "signedin": { + "title": "¡Bienvenido {user}!", + "description": "Has iniciado sesión.", + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } + }, + "verify": { + "userIdMissing": "¡No se proporcionó userId!", + "successTitle": "Usuario verificado", + "successDescription": "El usuario ha sido verificado con éxito.", + "setupAuthenticator": "Configurar autenticador", + "verify": { + "title": "Verificar usuario", + "description": "Introduce el código proporcionado en el correo electrónico de verificación.", + "noCodeReceived": "¿No recibiste un código?", + "resendCode": "Reenviar código", + "codeSent": "Se ha enviado un código a tu dirección de correo electrónico.", + "submit": "Continuar" + } + }, + "authenticator": { + "title": "Seleccionar método de autenticación", + "description": "Selecciona el método con el que deseas autenticarte", + "noMethodsAvailable": "No hay métodos de autenticación disponibles", + "allSetup": "¡Ya has configurado un autenticador!", + "linkWithIDP": "o vincúlalo con un proveedor de identidad" + }, + "device": { + "usercode": { + "title": "Código del dispositivo", + "description": "Introduce el código.", + "submit": "Continuar" + }, + "request": { + "title": "{appName} desea conectarse:", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "submit": "Permitir", + "deny": "Denegar" + }, + "scope": { + "openid": "Verifica tu identidad.", + "email": "Accede a tu dirección de correo electrónico.", + "profile": "Accede a la información completa de tu perfil.", + "offline_access": "Permitir acceso sin conexión a tu cuenta." + } + }, + "error": { + "noUserCode": "¡No se proporcionó código de usuario!", + "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", + "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", + "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", + "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", + "tryagain": "Intentar de nuevo" + } +} diff --git a/login/apps/login/locales/it.json b/login/apps/login/locales/it.json new file mode 100644 index 0000000000..1229a1a4c0 --- /dev/null +++ b/login/apps/login/locales/it.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Indietro" + }, + "accounts": { + "title": "Account", + "description": "Seleziona l'account che desideri utilizzare.", + "addAnother": "Aggiungi un altro account", + "noResults": "Nessun account trovato" + }, + "logout": { + "title": "Esci", + "description": "Seleziona l'account che desideri uscire", + "noResults": "Nessun account trovato", + "clear": "Elimina sessione", + "verifiedAt": "Ultima attività: {time}", + "success": { + "title": "Uscita riuscita", + "description": "Hai effettuato l'uscita con successo." + } + }, + "loginname": { + "title": "Bentornato!", + "description": "Inserisci i tuoi dati di accesso.", + "register": "Registrati come nuovo utente", + "submit": "Continua" + }, + "password": { + "verify": { + "title": "Password", + "description": "Inserisci la tua password.", + "resetPassword": "Reimposta Password", + "submit": "Continua" + }, + "set": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "codeSent": "Un codice è stato inviato al tuo indirizzo email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resend": "Invia di nuovo", + "submit": "Continua" + }, + "change": { + "title": "Cambia Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "idp": { + "title": "Accedi con SSO", + "description": "Seleziona uno dei seguenti provider per accedere", + "orSignInWith": "o accedi con", + "signInWithApple": "Accedi con Apple", + "signInWithGoogle": "Accedi con Google", + "signInWithAzureAD": "Accedi con AzureAD", + "signInWithGithub": "Accedi con GitHub", + "signInWithGitlab": "Accedi con GitLab", + "loginSuccess": { + "title": "Accesso riuscito", + "description": "Accesso effettuato con successo!" + }, + "linkingSuccess": { + "title": "Account collegato", + "description": "Hai collegato con successo il tuo account!" + }, + "registerSuccess": { + "title": "Registrazione riuscita", + "description": "Registrazione effettuata con successo!" + }, + "loginError": { + "title": "Accesso fallito", + "description": "Si è verificato un errore durante il tentativo di accesso." + }, + "linkingError": { + "title": "Collegamento account fallito", + "description": "Si è verificato un errore durante il tentativo di collegare il tuo account." + }, + "completeRegister": { + "title": "Completa la registrazione", + "description": "Completa la registrazione del tuo account." + } + }, + "ldap": { + "title": "Accedi con LDAP", + "description": "Inserisci le tue credenziali LDAP.", + "username": "Nome utente", + "password": "Password", + "submit": "Continua" + }, + "mfa": { + "verify": { + "title": "Verifica la tua identità", + "description": "Scegli uno dei seguenti fattori.", + "noResults": "Nessun secondo fattore disponibile per la configurazione." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Scegli uno dei seguenti secondi fattori.", + "skip": "Salta" + } + }, + "otp": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "totpDescription": "Inserisci il codice dalla tua app di autenticazione.", + "smsDescription": "Inserisci il codice ricevuto via SMS.", + "emailDescription": "Inserisci il codice ricevuto via email.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "submit": "Continua" + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "totpDescription": "Scansiona il codice QR con la tua app di autenticazione.", + "smsDescription": "Inserisci il tuo numero di telefono per ricevere un codice via SMS.", + "emailDescription": "Inserisci il tuo indirizzo email per ricevere un codice via email.", + "totpRegisterDescription": "Scansiona il codice QR o naviga manualmente all'URL.", + "submit": "Continua" + } + }, + "passkey": { + "verify": { + "title": "Autenticati con una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "usePassword": "Usa password", + "submit": "Continua" + }, + "set": { + "title": "Configura una passkey", + "description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo", + "info": { + "description": "Una passkey è un metodo di autenticazione su un dispositivo come la tua impronta digitale, Apple FaceID o simili.", + "link": "Autenticazione senza password" + }, + "skip": "Salta", + "submit": "Continua" + } + }, + "u2f": { + "verify": { + "title": "Verifica l'autenticazione a 2 fattori", + "description": "Verifica il tuo account con il tuo dispositivo." + }, + "set": { + "title": "Configura l'autenticazione a 2 fattori", + "description": "Configura un dispositivo come secondo fattore.", + "submit": "Continua" + } + }, + "register": { + "methods": { + "passkey": "Passkey", + "password": "Password" + }, + "disabled": { + "title": "Registration disabled", + "description": "Registrazione disabilitata. Contatta l'amministratore di sistema per assistenza." + }, + "missingdata": { + "title": "Registrazione", + "description": "Inserisci i tuoi dati per registrarti." + }, + "title": "Registrati", + "description": "Crea il tuo account ZITADEL.", + "noMethodAvailableWarning": "Nessun metodo di autenticazione disponibile. Contatta l'amministratore di sistema per assistenza.", + "selectMethod": "Seleziona il metodo con cui desideri autenticarti", + "agreeTo": "Per registrarti devi accettare i termini e le condizioni", + "termsOfService": "Termini di Servizio", + "privacyPolicy": "Informativa sulla Privacy", + "submit": "Continua", + "orUseIDP": "o usa un Identity Provider", + "password": { + "title": "Imposta Password", + "description": "Imposta la password per il tuo account", + "submit": "Continua" + } + }, + "invite": { + "title": "Invita Utente", + "description": "Inserisci l'indirizzo email dell'utente che desideri invitare.", + "info": "L'utente riceverà un'email con ulteriori istruzioni.", + "notAllowed": "Non hai i permessi per invitare un utente.", + "submit": "Invita Utente", + "success": { + "title": "Invito inviato", + "description": "L'utente è stato invitato con successo.", + "verified": "L'utente è stato invitato e ha già verificato la sua email.", + "notVerifiedYet": "L'utente è stato invitato. Riceverà un'email con ulteriori istruzioni.", + "submit": "Invita un altro utente" + } + }, + "signedin": { + "title": "Benvenuto {user}!", + "description": "Sei connesso.", + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } + }, + "verify": { + "userIdMissing": "Nessun userId fornito!", + "successTitle": "Utente verificato", + "successDescription": "L'utente è stato verificato con successo.", + "setupAuthenticator": "Configura autenticatore", + "verify": { + "title": "Verifica utente", + "description": "Inserisci il codice fornito nell'email di verifica.", + "noCodeReceived": "Non hai ricevuto un codice?", + "resendCode": "Invia di nuovo il codice", + "codeSent": "Un codice è stato appena inviato al tuo indirizzo email.", + "submit": "Continua" + } + }, + "authenticator": { + "title": "Seleziona metodo di autenticazione", + "description": "Seleziona il metodo con cui desideri autenticarti", + "noMethodsAvailable": "Nessun metodo di autenticazione disponibile", + "allSetup": "Hai già configurato un autenticatore!", + "linkWithIDP": "o collega con un Identity Provider" + }, + "device": { + "usercode": { + "title": "Codice dispositivo", + "description": "Inserisci il codice.", + "submit": "Continua" + }, + "request": { + "title": "{appName} desidera connettersi:", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "submit": "Consenti", + "deny": "Nega" + }, + "scope": { + "openid": "Verifica la tua identità.", + "email": "Accedi al tuo indirizzo email.", + "profile": "Accedi alle informazioni complete del tuo profilo.", + "offline_access": "Consenti l'accesso offline al tuo account." + } + }, + "error": { + "noUserCode": "Nessun codice utente fornito!", + "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", + "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", + "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", + "failedLoading": "Impossibile caricare i dati. Riprova.", + "tryagain": "Riprova" + } +} diff --git a/login/apps/login/locales/pl.json b/login/apps/login/locales/pl.json new file mode 100644 index 0000000000..9fea6a19fa --- /dev/null +++ b/login/apps/login/locales/pl.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Powrót" + }, + "accounts": { + "title": "Konta", + "description": "Wybierz konto, którego chcesz użyć.", + "addAnother": "Dodaj kolejne konto", + "noResults": "Nie znaleziono kont" + }, + "logout": { + "title": "Wyloguj się", + "description": "Wybierz konto, które chcesz usunąć", + "noResults": "Nie znaleziono kont", + "clear": "Usuń sesję", + "verifiedAt": "Ostatnia aktywność: {time}", + "success": { + "title": "Wylogowanie udane", + "description": "Pomyślnie się wylogowałeś." + } + }, + "loginname": { + "title": "Witamy ponownie!", + "description": "Wprowadź dane logowania.", + "register": "Zarejestruj nowego użytkownika", + "submit": "Kontynuuj" + }, + "password": { + "verify": { + "title": "Hasło", + "description": "Wprowadź swoje hasło.", + "resetPassword": "Zresetuj hasło", + "submit": "Kontynuuj" + }, + "set": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "codeSent": "Kod został wysłany na twój adres e-mail.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resend": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "change": { + "title": "Zmień hasło", + "description": "Ustaw nowe hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "idp": { + "title": "Zaloguj się za pomocą SSO", + "description": "Wybierz jednego z poniższych dostawców, aby się zalogować", + "orSignInWith": "lub zaloguj się przez", + "signInWithApple": "Zaloguj się przez Apple", + "signInWithGoogle": "Zaloguj się przez Google", + "signInWithAzureAD": "Zaloguj się przez AzureAD", + "signInWithGithub": "Zaloguj się przez GitHub", + "signInWithGitlab": "Zaloguj się przez GitLab", + "loginSuccess": { + "title": "Logowanie udane", + "description": "Zostałeś pomyślnie zalogowany!" + }, + "linkingSuccess": { + "title": "Konto powiązane", + "description": "Pomyślnie powiązałeś swoje konto!" + }, + "registerSuccess": { + "title": "Rejestracja udana", + "description": "Pomyślnie się zarejestrowałeś!" + }, + "loginError": { + "title": "Logowanie nieudane", + "description": "Wystąpił błąd podczas próby logowania." + }, + "linkingError": { + "title": "Powiązanie konta nie powiodło się", + "description": "Wystąpił błąd podczas próby powiązania konta." + }, + "completeRegister": { + "title": "Ukończ rejestrację", + "description": "Ukończ rejestrację swojego konta." + } + }, + "ldap": { + "title": "Zaloguj się przez LDAP", + "description": "Wprowadź swoje dane logowania LDAP.", + "username": "Nazwa użytkownika", + "password": "Hasło", + "submit": "Kontynuuj" + }, + "mfa": { + "verify": { + "title": "Zweryfikuj swoją tożsamość", + "description": "Wybierz jeden z poniższych sposobów weryfikacji.", + "noResults": "Nie znaleziono dostępnych metod uwierzytelniania dwuskładnikowego." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Wybierz jedną z poniższych metod drugiego czynnika.", + "skip": "Pomiń" + } + }, + "otp": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Wprowadź kod z aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź kod otrzymany SMS-em.", + "emailDescription": "Wprowadź kod otrzymany e-mailem.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "totpDescription": "Zeskanuj kod QR za pomocą aplikacji uwierzytelniającej.", + "smsDescription": "Wprowadź swój numer telefonu, aby otrzymać kod SMS-em.", + "emailDescription": "Wprowadź swój adres e-mail, aby otrzymać kod e-mailem.", + "totpRegisterDescription": "Zeskanuj kod QR lub otwórz adres URL ręcznie.", + "submit": "Kontynuuj" + } + }, + "passkey": { + "verify": { + "title": "Uwierzytelnij się za pomocą klucza dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "usePassword": "Użyj hasła", + "submit": "Kontynuuj" + }, + "set": { + "title": "Skonfiguruj klucz dostępu", + "description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.", + "info": { + "description": "Klucz dostępu to metoda uwierzytelniania na urządzeniu, wykorzystująca np. odcisk palca, Apple FaceID lub podobne rozwiązania.", + "link": "Uwierzytelnianie bez hasła" + }, + "skip": "Pomiń", + "submit": "Kontynuuj" + } + }, + "u2f": { + "verify": { + "title": "Zweryfikuj uwierzytelnianie dwuskładnikowe", + "description": "Zweryfikuj swoje konto za pomocą urządzenia." + }, + "set": { + "title": "Skonfiguruj uwierzytelnianie dwuskładnikowe", + "description": "Skonfiguruj urządzenie jako dodatkowy czynnik uwierzytelniania.", + "submit": "Kontynuuj" + } + }, + "register": { + "methods": { + "passkey": "Klucz dostępu", + "password": "Hasło" + }, + "disabled": { + "title": "Rejestracja wyłączona", + "description": "Rejestracja jest wyłączona. Skontaktuj się z administratorem." + }, + "missingdata": { + "title": "Brak danych", + "description": "Podaj e-mail, imię i nazwisko, aby się zarejestrować." + }, + "title": "Rejestracja", + "description": "Utwórz konto ZITADEL.", + "noMethodAvailableWarning": "Brak dostępnych metod uwierzytelniania. Skontaktuj się z administratorem.", + "selectMethod": "Wybierz metodę uwierzytelniania, której chcesz użyć", + "agreeTo": "Aby się zarejestrować, musisz zaakceptować warunki korzystania", + "termsOfService": "Regulamin", + "privacyPolicy": "Polityka prywatności", + "submit": "Kontynuuj", + "orUseIDP": "lub użyj dostawcy tożsamości", + "password": { + "title": "Ustaw hasło", + "description": "Ustaw hasło dla swojego konta", + "submit": "Kontynuuj" + } + }, + "invite": { + "title": "Zaproś użytkownika", + "description": "Podaj adres e-mail oraz imię i nazwisko użytkownika, którego chcesz zaprosić.", + "info": "Użytkownik otrzyma e-mail z dalszymi instrukcjami.", + "notAllowed": "Twoje ustawienia nie pozwalają na zapraszanie użytkowników.", + "submit": "Kontynuuj", + "success": { + "title": "Użytkownik zaproszony", + "description": "E-mail został pomyślnie wysłany.", + "verified": "Użytkownik został zaproszony i już zweryfikował swój e-mail.", + "notVerifiedYet": "Użytkownik został zaproszony. Otrzyma e-mail z dalszymi instrukcjami.", + "submit": "Zaproś kolejnego użytkownika" + } + }, + "signedin": { + "title": "Witaj {user}!", + "description": "Jesteś zalogowany.", + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } + }, + "verify": { + "userIdMissing": "Nie podano identyfikatora użytkownika!", + "successTitle": "Weryfikacja zakończona", + "successDescription": "Użytkownik został pomyślnie zweryfikowany.", + "setupAuthenticator": "Skonfiguruj uwierzytelnianie", + "verify": { + "title": "Zweryfikuj użytkownika", + "description": "Wprowadź kod z wiadomości weryfikacyjnej.", + "noCodeReceived": "Nie otrzymałeś kodu?", + "resendCode": "Wyślij kod ponownie", + "codeSent": "Kod został właśnie wysłany na twój adres e-mail.", + "submit": "Kontynuuj" + } + }, + "authenticator": { + "title": "Wybierz metodę uwierzytelniania", + "description": "Wybierz metodę, której chcesz użyć do uwierzytelnienia.", + "noMethodsAvailable": "Brak dostępnych metod uwierzytelniania", + "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", + "linkWithIDP": "lub połącz z dostawcą tożsamości" + }, + "device": { + "usercode": { + "title": "Kod urządzenia", + "description": "Wprowadź kod.", + "submit": "Kontynuuj" + }, + "request": { + "title": "{appName} chce się połączyć:", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "submit": "Zezwól", + "deny": "Odmów" + }, + "scope": { + "openid": "Zweryfikuj swoją tożsamość.", + "email": "Uzyskaj dostęp do swojego adresu e-mail.", + "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", + "offline_access": "Zezwól na dostęp offline do swojego konta." + } + }, + "error": { + "noUserCode": "Nie podano kodu użytkownika!", + "noDeviceRequest": "Nie znaleziono żądania urządzenia.", + "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", + "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", + "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", + "tryagain": "Spróbuj ponownie" + } +} diff --git a/login/apps/login/locales/ru.json b/login/apps/login/locales/ru.json new file mode 100644 index 0000000000..e745f1ae59 --- /dev/null +++ b/login/apps/login/locales/ru.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "Назад" + }, + "accounts": { + "title": "Аккаунты", + "description": "Выберите аккаунт, который хотите использовать.", + "addAnother": "Добавить другой аккаунт", + "noResults": "Аккаунты не найдены" + }, + "logout": { + "title": "Выход", + "description": "Выберите аккаунт, который хотите удалить", + "noResults": "Аккаунты не найдены", + "clear": "Удалить сессию", + "verifiedAt": "Последняя активность: {time}", + "success": { + "title": "Выход выполнен успешно", + "description": "Вы успешно вышли из системы." + } + }, + "loginname": { + "title": "С возвращением!", + "description": "Введите свои данные для входа.", + "register": "Зарегистрировать нового пользователя", + "submit": "Продолжить" + }, + "password": { + "verify": { + "title": "Пароль", + "description": "Введите ваш пароль.", + "resetPassword": "Сбросить пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "codeSent": "Код отправлен на ваш адрес электронной почты.", + "noCodeReceived": "Не получили код?", + "resend": "Отправить код повторно", + "submit": "Продолжить" + }, + "change": { + "title": "Изменить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "idp": { + "title": "Войти через SSO", + "description": "Выберите одного из провайдеров для входа", + "orSignInWith": "или войти через", + "signInWithApple": "Войти через Apple", + "signInWithGoogle": "Войти через Google", + "signInWithAzureAD": "Войти через AzureAD", + "signInWithGithub": "Войти через GitHub", + "signInWithGitlab": "Войти через GitLab", + "loginSuccess": { + "title": "Вход выполнен успешно", + "description": "Вы успешно вошли в систему!" + }, + "linkingSuccess": { + "title": "Аккаунт привязан", + "description": "Аккаунт успешно привязан!" + }, + "registerSuccess": { + "title": "Регистрация завершена", + "description": "Вы успешно зарегистрировались!" + }, + "loginError": { + "title": "Ошибка входа", + "description": "Произошла ошибка при попытке входа." + }, + "linkingError": { + "title": "Ошибка привязки аккаунта", + "description": "Произошла ошибка при попытке привязать аккаунт." + }, + "completeRegister": { + "title": "Завершите регистрацию", + "description": "Завершите регистрацию вашего аккаунта." + } + }, + "ldap": { + "title": "Войти через LDAP", + "description": "Введите ваши учетные данные LDAP.", + "username": "Имя пользователя", + "password": "Пароль", + "submit": "Продолжить" + }, + "mfa": { + "verify": { + "title": "Подтвердите вашу личность", + "description": "Выберите один из следующих факторов.", + "noResults": "Нет доступных методов двухфакторной аутентификации" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Выберите один из следующих методов.", + "skip": "Пропустить" + } + }, + "otp": { + "verify": { + "title": "Подтверждение 2FA", + "totpDescription": "Введите код из приложения-аутентификатора.", + "smsDescription": "Введите код, полученный по SMS.", + "emailDescription": "Введите код, полученный по email.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "totpDescription": "Отсканируйте QR-код в приложении-аутентификаторе.", + "smsDescription": "Введите номер телефона для получения кода по SMS.", + "emailDescription": "Введите email для получения кода.", + "totpRegisterDescription": "Отсканируйте QR-код или перейдите по ссылке вручную.", + "submit": "Продолжить" + } + }, + "passkey": { + "verify": { + "title": "Аутентификация с помощью пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "usePassword": "Использовать пароль", + "submit": "Продолжить" + }, + "set": { + "title": "Настройка пасскей", + "description": "Устройство запросит отпечаток пальца, лицо или экранный замок", + "info": { + "description": "Пасскей — метод аутентификации через устройство (отпечаток пальца, Apple FaceID и аналоги).", + "link": "Аутентификация без пароля" + }, + "skip": "Пропустить", + "submit": "Продолжить" + } + }, + "u2f": { + "verify": { + "title": "Подтверждение 2FA", + "description": "Подтвердите аккаунт с помощью устройства." + }, + "set": { + "title": "Настройка двухфакторной аутентификации", + "description": "Настройте устройство как второй фактор.", + "submit": "Продолжить" + } + }, + "register": { + "methods": { + "passkey": "Пасскей", + "password": "Пароль" + }, + "disabled": { + "title": "Регистрация отключена", + "description": "Регистрация недоступна. Обратитесь к администратору." + }, + "missingdata": { + "title": "Недостаточно данных", + "description": "Укажите email, имя и фамилию для регистрации." + }, + "title": "Регистрация", + "description": "Создайте свой аккаунт ZITADEL.", + "noMethodAvailableWarning": "Нет доступных методов аутентификации. Обратитесь к администратору.", + "selectMethod": "Выберите метод аутентификации", + "agreeTo": "Для регистрации необходимо принять условия:", + "termsOfService": "Условия использования", + "privacyPolicy": "Политика конфиденциальности", + "submit": "Продолжить", + "orUseIDP": "или используйте Identity Provider", + "password": { + "title": "Установить пароль", + "description": "Установите пароль для вашего аккаунта", + "submit": "Продолжить" + } + }, + "invite": { + "title": "Пригласить пользователя", + "description": "Укажите email и имя пользователя для приглашения.", + "info": "Пользователь получит email с инструкциями.", + "notAllowed": "Ваши настройки не позволяют приглашать пользователей.", + "submit": "Продолжить", + "success": { + "title": "Пользователь приглашён", + "description": "Письмо успешно отправлено.", + "verified": "Пользователь приглашён и уже подтвердил email.", + "notVerifiedYet": "Пользователь приглашён. Он получит email с инструкциями.", + "submit": "Пригласить другого пользователя" + } + }, + "signedin": { + "title": "Добро пожаловать, {user}!", + "description": "Вы вошли в систему.", + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } + }, + "verify": { + "userIdMissing": "Не указан userId!", + "successTitle": "Пользователь подтверждён", + "successDescription": "Пользователь успешно подтверждён.", + "setupAuthenticator": "Настроить аутентификатор", + "verify": { + "title": "Подтверждение пользователя", + "description": "Введите код из письма подтверждения.", + "noCodeReceived": "Не получили код?", + "resendCode": "Отправить код повторно", + "codeSent": "Код отправлен на ваш email.", + "submit": "Продолжить" + } + }, + "authenticator": { + "title": "Выбор метода аутентификации", + "description": "Выберите предпочитаемый метод аутентификации", + "noMethodsAvailable": "Нет доступных методов аутентификации", + "allSetup": "Аутентификатор уже настроен!", + "linkWithIDP": "или привязать через Identity Provider" + }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, + "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", + "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", + "sessionExpired": "Ваша сессия истекла. Войдите снова.", + "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", + "tryagain": "Попробовать снова" + } +} diff --git a/login/apps/login/locales/zh.json b/login/apps/login/locales/zh.json new file mode 100644 index 0000000000..5a9cb3a4eb --- /dev/null +++ b/login/apps/login/locales/zh.json @@ -0,0 +1,250 @@ +{ + "common": { + "back": "返回" + }, + "accounts": { + "title": "账户", + "description": "选择您想使用的账户。", + "addAnother": "添加另一个账户", + "noResults": "未找到账户" + }, + "logout": { + "title": "注销", + "description": "选择您想注销的账户", + "noResults": "未找到账户", + "clear": "注销会话", + "verifiedAt": "最后活动时间:{time}", + "success": { + "title": "注销成功", + "description": "您已成功注销。" + } + }, + "loginname": { + "title": "欢迎回来!", + "description": "请输入您的登录信息。", + "register": "注册新用户", + "submit": "继续" + }, + "password": { + "verify": { + "title": "密码", + "description": "请输入您的密码。", + "resetPassword": "重置密码", + "submit": "继续" + }, + "set": { + "title": "设置密码", + "description": "为您的账户设置密码", + "codeSent": "验证码已发送到您的邮箱。", + "noCodeReceived": "没有收到验证码?", + "resend": "重发验证码", + "submit": "继续" + }, + "change": { + "title": "更改密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "idp": { + "title": "使用 SSO 登录", + "description": "选择以下提供商中的一个进行登录", + "orSignInWith": "或使用以下方式登录", + "signInWithApple": "用 Apple 登录", + "signInWithGoogle": "用 Google 登录", + "signInWithAzureAD": "用 AzureAD 登录", + "signInWithGithub": "用 GitHub 登录", + "signInWithGitlab": "用 GitLab 登录", + "loginSuccess": { + "title": "登录成功", + "description": "您已成功登录!" + }, + "linkingSuccess": { + "title": "账户已链接", + "description": "您已成功链接您的账户!" + }, + "registerSuccess": { + "title": "注册成功", + "description": "您已成功注册!" + }, + "loginError": { + "title": "登录失败", + "description": "登录时发生错误。" + }, + "linkingError": { + "title": "账户链接失败", + "description": "链接账户时发生错误。" + }, + "completeRegister": { + "title": "完成注册", + "description": "完成您的账户注册。" + } + }, + "ldap": { + "title": "使用 LDAP 登录", + "description": "请输入您的 LDAP 凭据。", + "username": "用户名", + "password": "密码", + "submit": "继续" + }, + "mfa": { + "verify": { + "title": "验证您的身份", + "description": "选择以下的一个因素。", + "noResults": "没有可设置的第二因素。" + }, + "set": { + "title": "设置双因素认证", + "description": "选择以下的一个第二因素。", + "skip": "跳过" + } + }, + "otp": { + "verify": { + "title": "验证双因素", + "totpDescription": "请输入认证应用程序中的验证码。", + "smsDescription": "输入通过短信收到的验证码。", + "emailDescription": "输入通过电子邮件收到的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "submit": "继续" + }, + "set": { + "title": "设置双因素认证", + "totpDescription": "使用认证应用程序扫描二维码。", + "smsDescription": "输入您的电话号码以接收短信验证码。", + "emailDescription": "输入您的电子邮箱地址以接收电子邮件验证码。", + "totpRegisterDescription": "扫描二维码或手动导航到URL。", + "submit": "继续" + } + }, + "passkey": { + "verify": { + "title": "使用密钥认证", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "usePassword": "使用密码", + "submit": "继续" + }, + "set": { + "title": "设置密钥", + "description": "您的设备将请求指纹、面部识别或屏幕锁", + "info": { + "description": "密钥是在设备上如指纹、Apple FaceID 或类似的认证方法。", + "link": "无密码认证" + }, + "skip": "跳过", + "submit": "继续" + } + }, + "u2f": { + "verify": { + "title": "验证双因素", + "description": "使用您的设备验证帐户。" + }, + "set": { + "title": "设置双因素认证", + "description": "设置设备为第二因素。", + "submit": "继续" + } + }, + "register": { + "methods": { + "passkey": "密钥", + "password": "密码" + }, + "disabled": { + "title": "注册已禁用", + "description": "您的设置不允许注册新用户。" + }, + "missingdata": { + "title": "缺少数据", + "description": "请提供所有必需的数据。" + }, + "title": "注册", + "description": "创建您的 ZITADEL 账户。", + "noMethodAvailableWarning": "没有可用的认证方法。请联系您的系统管理员。", + "selectMethod": "选择您想使用的认证方法", + "agreeTo": "注册即表示您同意条款和条件", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "submit": "继续", + "orUseIDP": "或使用身份提供者", + "password": { + "title": "设置密码", + "description": "为您的账户设置密码", + "submit": "继续" + } + }, + "invite": { + "title": "邀请用户", + "description": "提供您想邀请的用户的电子邮箱地址和姓名。", + "info": "用户将收到一封包含进一步说明的电子邮件。", + "notAllowed": "您的设置不允许邀请用户。", + "submit": "继续", + "success": { + "title": "用户已邀请", + "description": "邮件已成功发送。", + "verified": "用户已被邀请并已验证其电子邮件。", + "notVerifiedYet": "用户已被邀请。他们将收到一封包含进一步说明的电子邮件。", + "submit": "邀请另一位用户" + } + }, + "signedin": { + "title": "欢迎 {user}!", + "description": "您已登录。", + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } + }, + "verify": { + "userIdMissing": "未提供用户 ID!", + "successTitle": "用户已验证", + "successDescription": "用户已成功验证。", + "setupAuthenticator": "设置认证器", + "verify": { + "title": "验证用户", + "description": "输入验证邮件中的验证码。", + "noCodeReceived": "没有收到验证码?", + "resendCode": "重发验证码", + "codeSent": "刚刚发送了一封包含验证码的电子邮件。", + "submit": "继续" + } + }, + "authenticator": { + "title": "选择认证方式", + "description": "选择您想使用的认证方法", + "noMethodsAvailable": "没有可用的认证方法", + "allSetup": "您已经设置好了一个认证器!", + "linkWithIDP": "或将其与身份提供者关联" + }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, + "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", + "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", + "sessionExpired": "当前会话已过期,请重新登录。", + "failedLoading": "加载数据失败,请再试一次。", + "tryagain": "重试" + } +} diff --git a/login/apps/login/next-env-vars.d.ts b/login/apps/login/next-env-vars.d.ts new file mode 100644 index 0000000000..b7a525858c --- /dev/null +++ b/login/apps/login/next-env-vars.d.ts @@ -0,0 +1,33 @@ +declare namespace NodeJS { + interface ProcessEnv { + // Allow any environment variable that matches the pattern + [key: `${string}_AUDIENCE`]: string; // The system api url + [key: `${string}_SYSTEM_USER_ID`]: string; // The service user id + [key: `${string}_SYSTEM_USER_PRIVATE_KEY`]: string; // The service user private key + + AUDIENCE: string; // The fallback system api url + SYSTEM_USER_ID: string; // The fallback service user id + SYSTEM_USER_PRIVATE_KEY: string; // The fallback service user private key + + /** + * The Zitadel API url + */ + ZITADEL_API_URL: string; + + /** + * The service user token + */ + ZITADEL_SERVICE_USER_TOKEN: string; + + /** + * Optional: wheter a user must have verified email + */ + EMAIL_VERIFICATION: string; + + /** + * Optional: custom request headers to be added to every request + * Split by comma, key value pairs separated by colon + */ + CUSTOM_REQUEST_HEADERS?: string; + } +} diff --git a/login/apps/login/next-env.d.ts b/login/apps/login/next-env.d.ts new file mode 100755 index 0000000000..1b3be0840f --- /dev/null +++ b/login/apps/login/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/login/apps/login/next.config.mjs b/login/apps/login/next.config.mjs new file mode 100755 index 0000000000..b84f11a230 --- /dev/null +++ b/login/apps/login/next.config.mjs @@ -0,0 +1,83 @@ +import createNextIntlPlugin from "next-intl/plugin"; +import { DEFAULT_CSP } from "./constants/csp.js"; + +const withNextIntl = createNextIntlPlugin(); + +/** @type {import('next').NextConfig} */ + +const secureHeaders = [ + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Referrer-Policy", + value: "origin-when-cross-origin", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Content-Security-Policy", + value: `${DEFAULT_CSP} frame-ancestors 'none'`, + }, + { key: "X-Frame-Options", value: "deny" }, +]; + +const imageRemotePatterns = [ + { + protocol: "http", + hostname: "localhost", + port: "8080", + pathname: "/**", + }, + { + protocol: "https", + hostname: "*.zitadel.*", + port: "", + pathname: "/**", + }, +]; + +if (process.env.ZITADEL_API_URL) { + imageRemotePatterns.push({ + protocol: "https", + hostname: process.env.ZITADEL_API_URL?.replace("https://", "") || "", + port: "", + pathname: "/**", + }); +} + +const nextConfig = { + basePath: process.env.NEXT_PUBLIC_BASE_PATH, + output: process.env.NEXT_OUTPUT_MODE || undefined, + reactStrictMode: true, // Recommended for the `pages` directory, default in `app`. + experimental: { + dynamicIO: true, + }, + images: { + remotePatterns: imageRemotePatterns, + }, + eslint: { + ignoreDuringBuilds: true, + }, + async headers() { + return [ + { + source: "/:path*", + headers: secureHeaders, + }, + ]; + }, +}; + +export default withNextIntl(nextConfig); diff --git a/login/apps/login/package.json b/login/apps/login/package.json new file mode 100644 index 0000000000..f498b912c2 --- /dev/null +++ b/login/apps/login/package.json @@ -0,0 +1,75 @@ +{ + "name": "@zitadel/login", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm next dev --turbopack", + "test:unit": "pnpm vitest", + "test:unit:standalone": "pnpm test:unit", + "test:unit:watch": "pnpm test:unit --watch", + "lint": "pnpm exec next lint && pnpm exec prettier --check .", + "lint:fix": "pnpm exec prettier --write .", + "lint-staged": "lint-staged", + "build": "pnpm exec next build", + "build:login:standalone": "NEXT_PUBLIC_BASE_PATH=/ui/v2/login NEXT_OUTPUT_MODE=standalone pnpm build", + "start": "pnpm build && pnpm exec next start", + "start:built": "pnpm exec next start", + "clean": "pnpm mock:stop && rm -rf .turbo && rm -rf node_modules && rm -rf .next" + }, + "git": { + "pre-commit": "lint-staged" + }, + "lint-staged": { + "*": "prettier --write --ignore-unknown" + }, + "dependencies": { + "@headlessui/react": "^2.1.9", + "@heroicons/react": "2.1.3", + "@tailwindcss/forms": "0.5.7", + "@vercel/analytics": "^1.2.2", + "@zitadel/client": "workspace:*", + "@zitadel/proto": "workspace:*", + "clsx": "1.2.1", + "copy-to-clipboard": "^3.3.3", + "deepmerge": "^4.3.1", + "lucide-react": "0.469.0", + "moment": "^2.29.4", + "next": "15.4.0-canary.86", + "next-intl": "^3.25.1", + "next-themes": "^0.2.1", + "nice-grpc": "2.0.1", + "qrcode.react": "^3.1.0", + "react": "19.1.0", + "react-dom": "19.1.0", + "react-hook-form": "7.39.5", + "tinycolor2": "1.4.2", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@bufbuild/buf": "^1.53.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/ms": "2.1.0", + "@types/node": "^22.14.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "^10.0.0", + "@vercel/git-hooks": "1.0.0", + "@zitadel/eslint-config": "workspace:*", + "@zitadel/prettier-config": "workspace:*", + "@zitadel/tailwind-config": "workspace:*", + "@zitadel/tsconfig": "workspace:*", + "autoprefixer": "10.4.21", + "grpc-tools": "1.13.0", + "jsdom": "^26.1.0", + "lint-staged": "15.5.1", + "make-dir-cli": "4.0.0", + "postcss": "8.5.3", + "prettier-plugin-tailwindcss": "0.6.11", + "sass": "^1.87.0", + "tailwindcss": "3.4.14", + "ts-proto": "^2.7.0", + "typescript": "^5.8.3" + } +} diff --git a/login/apps/login/postcss.config.cjs b/login/apps/login/postcss.config.cjs new file mode 100644 index 0000000000..12a703d900 --- /dev/null +++ b/login/apps/login/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/login/apps/login/prettier.config.mjs b/login/apps/login/prettier.config.mjs new file mode 100644 index 0000000000..6df557c2fd --- /dev/null +++ b/login/apps/login/prettier.config.mjs @@ -0,0 +1 @@ +export { default } from "@zitadel/prettier-config"; diff --git a/login/apps/login/public/checkbox.svg b/login/apps/login/public/checkbox.svg new file mode 100644 index 0000000000..94a3298ae6 --- /dev/null +++ b/login/apps/login/public/checkbox.svg @@ -0,0 +1 @@ + diff --git a/login/apps/login/public/favicon.ico b/login/apps/login/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a901eddc34fa1384048e3f1242c82cd36483ecca GIT binary patch literal 15086 zcmdU#d$3hi9mhAK@s}h2IHUIPRvJ=LGz}O`1=UnKHPcKlo6@40CXt6c)E-iBQ&TCF z@DXK_uO`%pjC?c!c_mUyr>2LBmUsz7Q6U0El*|40{hWQ)x%=+3_u2bAZor=Towd(e z>-YP8e`~G1)^Dx#i=tuC@M!GV$Z1z}+sRQhK8m8QuIm4hQMA!{Bb`z{^~fl?%oR>? z9kWDJOypOdlj5Y4zvrH)qfO&YGflUe9x**@+G{#cCEm%Sj5^eX=jcviDJ#o*hnq&3 z=9<=+wwd}&DoNCZ2VP_#Yj|1xt=6-SVWv|}i%tJF^_$9K&Z~!vO(tX_dswUZ8u3}~ zv8H(@aOYuZHy_#PIJOa%X3H#Zg6VfA#&f$Ei}=xjt_jWJOxL#z#`#&Mp=|R!d(esQ zmc9$~9c7wr+G)zuG1U3!2E$S5wr%v^^=Wtb_LznmW08C?fTeUlxGw#M?M1RXT0Zsl z(mAh_roN$0FIdiM)aIeg^(LB_&pL`NOJ2YW=JVbnEto9bFkQOzBeub|qp3RR+7r0)=7(yo4=H;~g7?-s@ zZYpEKcu8l!H69DMTp<17HVJGm?;ZrV?$7$=b>k-8-(Vb*w88&}nOMKmbqoI|$HdH& zrTOoZesqqs=6dO0f0A}@m--LX!OYC)@0YMO^j8BsV6FKXe#d|6ZBV~vM-ne%`0L}O zC7+j8Et39mpS0r{sc(NKyu!@gFUI}Fm@B{n)={Ak{EiQp&666#=f7QA_;Km>`O?Fy zr01UU7=F1BTB!>Ebbm4C&b7{5JOeoAg5UM<-&d#WSLR{2!>rNL#UGMxnj@|IskG_u z(%xN$1r__?3@kc+T5%Sd;crlJXiXP*Wk*Sa6dMZ z-)0%u3-^T0+OX$8u?=lvD`Pj<3-N>F5VZm94aZNi4G%#Zz@Gd4Cb5V2L(B%SXU?Ai zZ&rE;*#P$X^{c^r6zn-fYyf+m{}3=1r3cxD;6IEv&O!cL5$+olGK&nKeoJoPuo-0hQF@1FWQ{HpTs|C|6c8bF~-vN`3d%vu>rrl zMW4U!`~>_w);-VEk@KrYHtc&zT6|_p_;b&%s`xv4ewb&&)+eN|y({h~`l|_A>$u!Wp0?p<^E`IEzp;+8RQmk9ihsCe&o*`J z{sJ2~6IgESoR60{V+*!5eScB4q4W1g)x9KV4kg>3cBdVix<3LxkNM~a?qUwR`@7hJ z_}BOk%lp%m4TtUhb+E(zfS)lG@dJ#{A&nmhHjr8u-^F1Uzmc>dyxR`;5A1>1$FZLy zM&?-)X9JvXgfkBED5DN_;b}X5M(Q0QMNy6RMbU};-x)>6koH8;aMEta5s?DiKmDW& zqNtB_QWTlZj8SAZGY+J^{2viTJ*2*YPN&`E`z9!zlHJLt8(|rGddSbf)l0r{r|k)L z2D?K$xJfGQQfsH4YP*GY4DITQaa+gjPMlVkX|kynr!~@lT6jDjjx>!n%`mMnJz?5q z3gd`(@+hMYbsaI*-eVbjkBrBqrl(E&O+{-Yb*Kvuyrtt4l4#9$yy*(l^QI!5dFAjT zB$ei2=gUhkIeckq_YWL-X&*_n! z*$&P>!9+W?WM4%eF%Z7f;BKS){nAg)i|=D+onn~-IM8uIvK{>Y2ovL^v~K*~aNjfC z{_jPOxw!U=z7tyFyP@C5F&Fal;EK#W3A&C582o;ksqtA^(8vD3ekyll#B*?;$lV!t zX!}z!6Wrx;2bi&^K-X#L2Z!kgraIe`eK_ZBKG*SGBJv)%-FJ+SIp)E4jg6k&I~tw^ zp%dNARU1te{h>`~I__rvB9!EbJ&Bvj8r5z)fET`H2*r=w`mG%xl~W zUiBI2uEo-$J&rlp+Iq|Z_nP-yldBs%=tMVjb_HKM_rra!O53+ce_JEnxzOhjpOxm0 zvD|FiLwlf`btC@oy6okC_1)q-YVO^+8-Ma4-^VYNmVR0K=6h7RYYu&w)9yhxX;{Z< zd0{`<2m`T9jQekY&~Xu?6LXEf{hBSorp77j=Ab)z_=_+QH^cop?c3s6RltevI{l?E z_}l*DO2_$`_G!8ODfg3pwze~2;g{V@j=`crZtJEM9*T?UILq zc%hqpf0MD^rt#F)-^3c+wEvNwtC^24`LOjD&x3>fk1B?y{jX#@u>O>cQ>*j8(9h%k zY1;n`7=C?a9Pi5iR6i&G=lY*9g4XplC-Pxo?|;Q zO7A}s7}$R;H2vTAUkMBcZT}fCkh%?qZ2zsD$9S_cOg?W|;IoHG94BzJ(Cbr*9v?M8 z-9ADH6cs$79%)aNzt^&miEMPB3!UiZoPuMCE>rECqTuAf?J>f1x@oy-i;1-a;+;Io ze9BV)IR7})64MS--59RsQAVBPk`nlDy3mo72D=+h&?Kjd3ns^e�bhF5v64Dt2EDBsZ?#{)Beuy)ZdRlbmh{*C=HWGotxL9FwYpRU;(c#Imw(ha>hZ%{>AyxY=nMAnDW5*{U)}gYR={!B zRnjvX;xUo3p)!6L$F83_ev!eM1$#EukpxD@de%39%)#G}WhC@wem~(`GVcgeQ{V4C Zp8@y!i|+AhUN`5hro1PVQx_h1{|6%=p(X$T literal 0 HcmV?d00001 diff --git a/login/apps/login/public/favicon/android-chrome-192x192.png b/login/apps/login/public/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..f22bd442e631e91a1941f4f9d8b37c1ea4181ed1 GIT binary patch literal 17828 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 Hu0mjftWRU2 literal 0 HcmV?d00001 diff --git a/login/apps/login/public/favicon/android-chrome-512x512.png b/login/apps/login/public/favicon/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6987ed11b45db3179879b2e0892a7afcdd891249 GIT binary patch literal 137768 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%0Alqi6e`R9*-RHY{A1J_h!rpC3ru6ZTA) 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 z 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/typescript LOGIN_REMOTE_BRANCH=`. + 4. Push your changes and open a pull request to zitadel/zitadel + `.trim(); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: "closed" + }); diff --git a/login/.github/workflows/issues.yml b/login/.github/workflows/issues.yml new file mode 100644 index 0000000000..ff12b8fe04 --- /dev/null +++ b/login/.github/workflows/issues.yml @@ -0,0 +1,41 @@ +name: Add new issues to product management project + +on: + issues: + types: + - opened + +jobs: + add-to-project: + name: Add issue and community pr to project + runs-on: ubuntu-latest + if: github.repository_id == '622995060' + steps: + - name: add issue + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'issues' }} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: tspascoal/get-user-teams-membership@v3 + id: checkUserMember + if: github.actor != 'dependabot[bot]' + with: + username: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} + - name: add pr + uses: actions/add-to-project@v1.0.2 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'engineers')}} + with: + # You can target a repository in a different organization + # to the issue + project-url: https://github.com/orgs/zitadel/projects/2 + github-token: ${{ secrets.ADD_TO_PROJECT_PAT }} + - uses: actions-ecosystem/action-add-labels@v1.1.3 + if: ${{ github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]' && !contains(steps.checkUserMember.outputs.teams, 'staff')}} + with: + github_token: ${{ secrets.ADD_TO_PROJECT_PAT }} + labels: | + os-contribution diff --git a/login/.github/workflows/release.yml b/login/.github/workflows/release.yml new file mode 100644 index 0000000000..2508627d1b --- /dev/null +++ b/login/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + runs-on: ubuntu-latest + if: github.repository_id != '622995060' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: pnpm install + + - name: Create Release Pull Request + uses: changesets/action@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/login/.github/workflows/test.yml b/login/.github/workflows/test.yml new file mode 100644 index 0000000000..7b4721dbee --- /dev/null +++ b/login/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Quality +on: + pull_request: + workflow_dispatch: + inputs: + ignore-run-cache: + description: 'Whether to ignore the run cache' + required: false + default: true + ref-tag: + description: 'overwrite the DOCKER_METADATA_OUTPUT_VERSION environment variable used by the make file' + required: false + default: '' +jobs: + quality: + name: Ensure Quality + if: github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.repository_id != '622995060') + runs-on: ubuntu-22.04 + timeout-minutes: 30 + permissions: + contents: read # We only need read access to the repository contents + actions: write # We need write access to the actions cache + env: + CACHE_DIR: /tmp/login-run-caches + # Only run this job on workflow_dispatch or pushes to forks + steps: + - uses: actions/checkout@v4 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/zitadel/login + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + # Only with correctly restored build cache layers, the run caches work as expected. + # To restore docker build layer caches, extend the docker-bake.hcl to use the cache-from and cache-to options. + # https://docs.docker.com/build/ci/github-actions/cache/ + # Alternatively, you can use a self-hosted runner or a third-party builder that restores build layer caches out-of-the-box, like https://depot.dev/ + - name: Restore Run Caches + uses: actions/cache/restore@v4 + id: run-caches-restore + with: + path: ${{ env.CACHE_DIR }} + key: ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}-${{github.run_attempt}} + restore-keys: | + ${{ runner.os }}-login-run-caches-${{github.ref_name}}-${{ github.sha }}- + ${{ runner.os }}-login-run-caches-${{github.ref_name}}- + ${{ runner.os }}-login-run-caches- + - run: make login_quality + env: + IGNORE_RUN_CACHE: ${{ github.event.inputs.ignore-run-cache == 'true' }} + DOCKER_METADATA_OUTPUT_VERSION: ${{ github.event.inputs.ref-tag || env.DOCKER_METADATA_OUTPUT_VERSION || steps.meta.outputs.version }} + - name: Save Run Caches + uses: actions/cache/save@v4 + with: + path: ${{ env.CACHE_DIR }} + key: ${{ steps.run-caches-restore.outputs.cache-primary-key }} + if: always() diff --git a/login/.gitignore b/login/.gitignore new file mode 100644 index 0000000000..8d49ae1b37 --- /dev/null +++ b/login/.gitignore @@ -0,0 +1,18 @@ +.DS_Store +node_modules +.turbo +*.log +.next +dist +dist-ssr +*.local +.env +server/dist +public/dist +.vscode +.idea +.vercel +.env*.local +/blob-report/ +/out +/docker diff --git a/login/.npmrc b/login/.npmrc new file mode 100644 index 0000000000..ded82e2f63 --- /dev/null +++ b/login/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/login/.nvmrc b/login/.nvmrc new file mode 100644 index 0000000000..0a47c855eb --- /dev/null +++ b/login/.nvmrc @@ -0,0 +1 @@ +lts/iron \ No newline at end of file diff --git a/login/.prettierignore b/login/.prettierignore new file mode 100644 index 0000000000..77415caa1e --- /dev/null +++ b/login/.prettierignore @@ -0,0 +1,9 @@ +.next/ +.changeset/ +.github/ +dist/ +standalone/ +packages/zitadel-proto/google +packages/zitadel-proto/protoc-gen-openapiv2 +packages/zitadel-proto/validate +packages/zitadel-proto/zitadel diff --git a/login/.prettierrc b/login/.prettierrc new file mode 100644 index 0000000000..ba42405b03 --- /dev/null +++ b/login/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 125, + "trailingComma": "all", + "plugins": ["prettier-plugin-organize-imports"], + "filepath": "" +} diff --git a/login/CODE_OF_CONDUCT.md b/login/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..ac3f129652 --- /dev/null +++ b/login/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +legal@zitadel.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/login/CONTRIBUTING.md b/login/CONTRIBUTING.md new file mode 100644 index 0000000000..783935984f --- /dev/null +++ b/login/CONTRIBUTING.md @@ -0,0 +1,206 @@ +# Contributing + +:attention: In this CONTRIBUTING.md you read about contributing to this very repository. +If you want to develop your own login UI, please refer [to the README.md](./README.md). + +## Introduction + +Thank you for your interest about how to contribute! + +:attention: If you notice a possible **security vulnerability**, please don't hesitate to disclose any concern by contacting [security@zitadel.com](mailto:security@zitadel.com). +You don't have to be perfectly sure about the nature of the vulnerability. +We will give them a high priority and figure them out. + +We also appreciate all your other ideas, thoughts and feedback and will take care of them as soon as possible. +We love to discuss in an open space using [GitHub issues](https://github.com/zitadel/typescript/issues), +[GitHub discussions in the core repo](https://github.com/zitadel/zitadel/discussions) +or in our [chat on Discord](https://zitadel.com/chat). +For private discussions, +you have [more contact options on our Website](https://zitadel.com/contact). + +## Pull Requests + +Please consider the following guidelines when creating a pull request. + +- The latest changes are always in `main`, so please make your pull request against that branch. +- pull requests should be raised for any change +- Pull requests need approval of a Zitadel core engineer @zitadel/engineers before merging +- We use ESLint/Prettier for linting/formatting, so please run `pnpm lint:fix` before committing to make resolving conflicts easier (VSCode users, check out [this ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) and [this Prettier extension](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode) to fix lint and formatting issues in development) +- If you add new functionality, please provide the corresponding documentation as well and make it part of the pull request + +### Setting up local environment + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server for the login and manually configure apps/login/.env.local +pnpm dev +``` + +The application is now available at `http://localhost:3000` + +Configure apps/login/.env.local to target the Zitadel instance of your choice. +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Local Latest Zitadel Release + +The following command uses Docker to run a local Zitadel instance and the login application in live-reloading dev mode. +Additionally, it runs a Traefik reverse proxy that exposes the login with a self-signed certificate at https://127.0.0.1.sslip.io +127.0.0.1.sslip.io is a special domain that resolves to your localhost, so it's safe to allow your browser to proceed with loading the page. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +pnpm dev:local +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Developing Against A Locally Compiled Zitadel + +To develop against a locally compiled version of Zitadel, you need to build the Zitadel docker image first. +Clone the [Zitadel repository](https://github.com/zitadel/zitadel.git) and run the following command from its root: + +```sh +# This compiles a Zitadel binary if it does not exist at ./zitadel already and copies it into a Docker image. +# If you want to recompile the binary, run `make compile` first +make login_dev +``` + +Open another terminal session at zitadel/zitadel/login and run the following commands to start the dev server. + +```bash +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Start a local development server and have apps/login/.env.test.local configured for you to target the local Zitadel instance. +NODE_ENV=test pnpm dev +``` + +Log in at https://127.0.0.1.sslip.io/ui/v2/login/loginname and use the following credentials: +**Loginname**: *zitadel-admin@zitadel.127.0.0.1.sslip.io* +**Password**: _Password1!_. + +The login app live-reloads on changes, so you can start developing right away. + +### Quality Assurance + +Use `make` commands to test the quality of your code against a production build without installing any dependencies besides Docker. +Using `make` commands, you can reproduce and debug the CI pipelines locally. + +```sh +# Reproduce the whole CI pipeline in docker +make login_quality +# Show other options with make +make help +``` + +Use `pnpm` commands to run the tests in dev mode with live reloading and debugging capabilities. + +#### Linting and formatting + +Check the formatting and linting of the code in docker + +```sh +make login_lint +``` + +Check the linting of the code using pnpm + +```sh +pnpm lint +pnpm format +``` + +Fix the linting of your code + +```sh +pnpm lint:fix +pnpm format:fix +``` + +#### Running Unit Tests + +Run the tests in docker + +```sh +make login_test_unit +``` + +Run unit tests with live-reloading + +```sh +pnpm test:unit +``` + +#### Running Integration Tests + +Run the test in docker + +```sh +make login_test_integration +``` + +Alternatively, run a live-reloading development server with an interactive Cypress test suite. +First, set up your local test environment. + +```sh +# Install dependencies. Developing requires Node.js v20 +pnpm install + +# Generate gRPC stubs +pnpm generate + +# Start a local development server and use apps/login/.env.test to use the locally mocked Zitadel API. +pnpm test:integration:setup +``` + +Now, in another terminal session, open the interactive Cypress integration test suite. + +```sh +pnpm test:integration open +``` + +Show more options with Cypress + +```sh +pnpm test:integration help +``` + +#### Running Acceptance Tests + +To run the tests in docker against the latest release of Zitadel, use the following command: + +:warning: The acceptance tests are not reliable at the moment :construction: + +```sh +make login_test_acceptance +``` + +Alternatively, run can use a live-reloading development server with an interactive Playwright test suite. +Set up your local environment by running the commands either for [developing against a local latest Zitadel release](latest) or for [developing against a locally compiled Zitadel](compiled). + +Now, in another terminal session, open the interactive Playwright acceptance test suite. + +```sh +pnpm test:acceptance open +``` + +Show more options with Playwright + +```sh +pnpm test:acceptance help +``` diff --git a/login/LICENSE b/login/LICENSE new file mode 100644 index 0000000000..89f750f2ab --- /dev/null +++ b/login/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 ZITADEL + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/login/Makefile b/login/Makefile new file mode 100644 index 0000000000..a6e781374b --- /dev/null +++ b/login/Makefile @@ -0,0 +1,137 @@ +XDG_CACHE_HOME ?= $(HOME)/.cache +export CACHE_DIR ?= $(XDG_CACHE_HOME)/zitadel-make + +LOGIN_DIR ?= ./ +LOGIN_BAKE_CLI ?= docker buildx bake +LOGIN_BAKE_CLI_WITH_ARGS := $(LOGIN_BAKE_CLI) --file $(LOGIN_DIR)docker-bake.hcl --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml +LOGIN_BAKE_CLI_ADDITIONAL_ARGS ?= +LOGIN_BAKE_CLI_WITH_ARGS += $(LOGIN_BAKE_CLI_ADDITIONAL_ARGS) + +export COMPOSE_BAKE=true +export UID := $(id -u) +export GID := $(id -g) + +export LOGIN_TEST_ACCEPTANCE_BUILD_CONTEXT := $(LOGIN_DIR)apps/login-test-acceptance + +export DOCKER_METADATA_OUTPUT_VERSION ?= local +export LOGIN_TAG ?= login:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_UNIT_TAG := login-test-unit:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_INTEGRATION_TAG := login-test-integration:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_TAG := login-test-acceptance:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SETUP_TAG := login-test-acceptance-setup:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SINK_TAG := login-test-acceptance-sink:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCRP_TAG := login-test-acceptance-oidcrp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_OIDCOP_TAG := login-test-acceptance-oidcop:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLSP_TAG := login-test-acceptance-samlsp:${DOCKER_METADATA_OUTPUT_VERSION} +export LOGIN_TEST_ACCEPTANCE_SAMLIDP_TAG := login-test-acceptance-samlidp:${DOCKER_METADATA_OUTPUT_VERSION} +export POSTGRES_TAG := postgres:17.0-alpine3.19 +export GOLANG_TAG := golang:1.24-alpine +export ZITADEL_TAG ?= ghcr.io/zitadel/zitadel:latest +export LOGIN_CORE_MOCK_TAG := login-core-mock:${DOCKER_METADATA_OUTPUT_VERSION} + +login_help: + @echo "Makefile for the login service" + @echo "Available targets:" + @echo " login_help - Show this help message." + @echo " login_quality - Run all quality checks (login_lint, login_test_unit, login_test_integration, login_test_acceptance)." + @echo " login_standalone_build - Build the docker image for production login containers." + @echo " login_lint - Run linting and formatting checks. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_unit - Run unit tests. Tests without any dependencies. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login-test_integration - Run integration tests. Tests a login production build against a mocked Zitadel core API. IGNORE_RUN_CACHE=true prevents skipping." + @echo " login_test_acceptance - Run acceptance tests. Tests a login production build with a local Zitadel instance behind a reverse proxy. IGNORE_RUN_CACHE=true prevents skipping." + @echo " typescript_generate - Generate TypeScript client code from Protobuf definitions." + @echo " show_run_caches - Show all run caches with image ids and exit codes." + @echo " clean_run_caches - Remove all run caches." + + +login_lint: + @echo "Running login linting and formatting checks" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-lint + +login_test_unit: + @echo "Running login unit tests" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-unit + +login_test_integration_build: + @echo "Building login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock login-test-integration login-standalone --load + +login_test_integration_dev: login_test_integration_cleanup + @echo "Starting login integration test environment with the local core mock image" + $(LOGIN_BAKE_CLI_WITH_ARGS) core-mock && docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --service-ports --rm core-mock + +login_test_integration_run: login_test_integration_cleanup + @echo "Running login integration tests" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml run --rm integration + +login_test_integration_cleanup: + @echo "Cleaning up login integration test environment" + docker compose --file $(LOGIN_DIR)apps/login-test-integration/docker-compose.yaml down --volumes + +login_test_integration: login_test_integration_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_integration_run \ + "$(LOGIN_TAG) \ + $(LOGIN_CORE_MOCK_TAG) \ + $(LOGIN_TEST_INTEGRATION_TAG)" + +login_test_acceptance_build_bake: + @echo "Building login test acceptance images as defined in the docker-bake.hcl" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-test-acceptance login-standalone --load + +login_test_acceptance_build_compose: + @echo "Building login test acceptance images as defined in the docker-compose.yaml" + $(LOGIN_BAKE_CLI_WITH_ARGS) --load setup sink + +# login_test_acceptance_build is overwritten by the login_dev target in zitadel/zitadel/Makefile +login_test_acceptance_build: login_test_acceptance_build_compose login_test_acceptance_build_bake + +login_test_acceptance_run: login_test_acceptance_cleanup + @echo "Running login test acceptance tests" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml run --rm --service-ports acceptance + +login_test_acceptance_cleanup: + @echo "Cleaning up login test acceptance environment" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose-ci.yaml down --volumes + +login_test_acceptance: login_test_acceptance_build + $(LOGIN_DIR)scripts/run_or_skip.sh login_test_acceptance_run \ + "$(LOGIN_TAG) \ + $(ZITADEL_TAG) \ + $(POSTGRES_TAG) \ + $(GOLANG_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SETUP_TAG) \ + $(LOGIN_TEST_ACCEPTANCE_SINK_TAG)" + +login_test_acceptance_setup_env: login_test_acceptance_build_compose login_test_acceptance_cleanup + @echo "Setting up the login test acceptance environment and writing the env.test.local file" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml run setup + +login_test_acceptance_setup_dev: + @echo "Starting the login test acceptance environment with the local zitadel image" + docker compose --file $(LOGIN_DIR)apps/login-test-acceptance/docker-compose.yaml up --no-recreate zitadel traefik sink + +login_quality: login_lint login_test_unit login_test_integration + @echo "Running login quality checks: lint, unit tests, integration tests" + +login_standalone_build: + @echo "Building the login standalone docker image with tag: $(LOGIN_TAG)" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone --load + +login_standalone_out: + $(LOGIN_BAKE_CLI_WITH_ARGS) login-standalone-out + +typescript_generate: + @echo "Generating TypeScript client and writing to local $(LOGIN_DIR)packages/zitadel-proto" + $(LOGIN_BAKE_CLI_WITH_ARGS) login-typescript-proto-client-out + +clean_run_caches: + @echo "Removing cache directory: $(CACHE_DIR)" + rm -rf "$(CACHE_DIR)" + +show_run_caches: + @echo "Showing run caches with docker image ids and exit codes in $(CACHE_DIR):" + @find "$(CACHE_DIR)" -type f 2>/dev/null | while read file; do \ + echo "$$file: $$(cat $$file)"; \ + done + diff --git a/login/README.md b/login/README.md new file mode 100644 index 0000000000..c3601e666b --- /dev/null +++ b/login/README.md @@ -0,0 +1,264 @@ +# ZITADEL TypeScript with Turborepo + +This repository contains all TypeScript and JavaScript packages and applications you need to create your own ZITADEL +Login UI. + +collage of login screens + +[![npm package](https://img.shields.io/npm/v/@zitadel/proto.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/proto) +[![npm package](https://img.shields.io/npm/v/@zitadel/client.svg?style=for-the-badge&logo=npm&logoColor=white)](https://www.npmjs.com/package/@zitadel/client) + +**⚠️ This repo and packages are in beta state and subject to change ⚠️** + +The scope of functionality of this repo and packages is under active development. + +The `@zitadel/client` package is using [@connectrpc/connect](https://github.com/connectrpc/connect-es#readme). + +You can read the [contribution guide](/CONTRIBUTING.md) on how to contribute. +Questions can be raised in our [Discord channel](https://discord.gg/erh5Brh7jE) or as +a [GitHub issue](https://github.com/zitadel/typescript/issues). + +## Developing Your Own ZITADEL Login UI + +We think the easiest path of getting up and running, is the following: + +1. Fork and clone this repository +1. [Run the ZITADEL Cloud login UI locally](#run-login-ui) +1. Make changes to the code and see the effects live on your local machine +1. Study the rest of this README.md and get familiar and comfortable with how everything works. +1. Decide on a way of how you want to build and run your login UI. + You can reuse ZITADEL Clouds way. + But if you need more freedom, you can also import the packages you need into your self built application. + +## Included Apps And Packages + +- `login`: The login UI used by ZITADEL Cloud, powered by Next.js +- `@zitadel/client`: shared client utilities for node and browser environments +- `@zitadel/proto`: Protocol Buffers (proto) definitions used by ZITADEL projects +- `@zitadel/tsconfig`: shared `tsconfig.json`s used throughout the monorepo +- `@zitadel/eslint-config`: ESLint preset + +Each package and app is 100% [TypeScript](https://www.typescriptlang.org/). + +### Login + +The login is currently in a work in progress state. +The goal is to implement a login UI, using the session API of ZITADEL, which also implements the OIDC Standard and is +ready to use for everyone. + +In the first phase we want to have a MVP login ready with the OIDC Standard and a basic feature set. In a second step +the features will be extended. + +This list should show the current implementation state, and also what is missing. +You can already use the current state, and extend it with your needs. + +#### Features list + +- [x] Local User Registration (with Password) +- [x] User Registration and Login with external Provider + - [x] Google + - [x] GitHub + - [x] GitHub Enterprise + - [x] GitLab + - [x] GitLab Enterprise + - [x] Azure + - [x] Apple + - [x] Generic OIDC + - [x] Generic OAuth + - [x] Generic JWT + - [x] LDAP + - [x] SAML SP +- Multifactor Registration an Login + - [x] Passkeys + - [x] TOTP + - [x] OTP: Email Code + - [x] OTP: SMS Code +- [x] Password Change/Reset +- [x] Domain Discovery +- [x] Branding +- OIDC Standard + + - [x] Authorization Code Flow with PKCE + - [x] AuthRequest `hintUserId` + - [x] AuthRequest `loginHint` + - [x] AuthRequest `prompt` + - [x] Login + - [x] Select Account + - [ ] Consent + - [x] Create + - Scopes + - [x] `openid email profile address`` + - [x] `offline access` + - [x] `urn:zitadel:iam:org:idp:id:{idp_id}` + - [x] `urn:zitadel:iam:org:project:id:zitadel:aud` + - [x] `urn:zitadel:iam:org:id:{orgid}` + - [x] `urn:zitadel:iam:org:domain:primary:{domain}` + - [ ] AuthRequest UI locales + + #### Flow diagram + + This diagram shows the available pages and flows. + + > Note that back navigation or retries are not displayed. + +```mermaid + flowchart TD + A[Start] --> register + A[Start] --> accounts + A[Start] --> loginname + loginname -- signInWithIDP --> idp-success + loginname -- signInWithIDP --> idp-failure + idp-success --> B[signedin] + loginname --> password + loginname -- hasPasskey --> passkey + loginname -- allowRegister --> register + passkey-add --passwordAllowed --> password + passkey -- hasPassword --> password + passkey --> B[signedin] + password -- hasMFA --> mfa + password -- allowPasskeys --> passkey-add + password -- reset --> password-set + email -- reset --> password-set + password-set --> B[signedin] + password-change --> B[signedin] + password -- userstate=initial --> password-change + + mfa --> otp + otp --> B[signedin] + mfa--> u2f + u2f -->B[signedin] + register -- password/passkey --> B[signedin] + password --> B[signedin] + password-- forceMFA -->mfaset + mfaset --> u2fset + mfaset --> otpset + u2fset --> B[signedin] + otpset --> B[signedin] + accounts--> loginname + password -- not verified yet -->verify + register-- withpassword -->verify + passkey-- notVerified --> verify + verify --> B[signedin] +``` + +You can find a more detailed documentation of the different pages [here](./apps/login/readme.md). + +#### Custom translations + +The new login uses the [SettingsApi](https://zitadel.com/docs/apis/resources/settings_service_v2/settings-service-get-hosted-login-translation) to load custom translations. +Translations can be overriden at both the instance and organization levels. +To find the keys more easily, you can inspect the HTML and search for a `data-i18n-key` attribute, or look at the defaults in `/apps/login/locales/[locale].ts`. +![Custom Translations](.github/custom-i18n.png) + +## Tooling + +- [TypeScript](https://www.typescriptlang.org/) for static type checking +- [ESLint](https://eslint.org/) for code linting +- [Prettier](https://prettier.io) for code formatting + +## Useful Commands + +- `make login-quality` - Check the quality of your code against a production build without installing any dependencies besides Docker +- `pnpm generate` - Build proto stubs for the client package +- `pnpm dev` - Develop all packages and the login app +- `pnpm build` - Build all packages and the login app +- `pnpm clean` - Clean up all `node_modules` and `dist` folders (runs each package's clean script) + +Learn more about developing the login UI in the [contribution guide](/CONTRIBUTING.md). + +## Versioning And Publishing Packages + +Package publishing has been configured using [Changesets](https://github.com/changesets/changesets). +Here is their [documentation](https://github.com/changesets/changesets#documentation) for more information about the +workflow. + +The [GitHub Action](https://github.com/changesets/action) needs an `NPM_TOKEN` and `GITHUB_TOKEN` in the repository +settings. The [Changesets bot](https://github.com/apps/changeset-bot) should also be installed on the GitHub repository. + +Read the [changesets documentation](https://github.com/changesets/changesets/blob/main/docs/automating-changesets.md) +for more information about this automation + +### Run Login UI + +To run the application make sure to install the dependencies with + +```sh +pnpm install +``` + +then generate the GRPC stubs with + +```sh +pnpm generate +``` + +To run the application against a local ZITADEL instance, run the following command: + +```sh +pnpm run-zitadel +``` + +This sets up ZITADEL using docker compose and writes the configuration to the file `apps/login/.env.local`. + +

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

wvBeYmA)c51m?GXru@LW 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[Lva9IM$XphWA)+E; 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 $@1U9CSV$;^ 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 literal 0 HcmV?d00001 diff --git a/docs/static/img/go/api-create_service_user.png b/docs/static/img/go/api-create_service_user.png new file mode 100644 index 0000000000000000000000000000000000000000..6af66bcbca0c3feff5e804cfe179733048e7fad9 GIT binary patch literal 43016 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